Skip to main content

praxis_runtime/
crash_snapshot.rs

1//! Crash snapshots: a stable, deep-copied view of the debug frames taken at the
2//! moment a fault begins to unwind (§9.3).
3//!
4//! When a fault fires, each generated function's fault epilogue restores the
5//! shadow and debug stack tops it saved as it returns to its caller. By the time
6//! control reaches the host, **every** language frame has unwound and both debug
7//! stacks are empty again. To give the host (the noninteractive fallback, the
8//! crash REPL) something to inspect, the **first** fault epilogue — the
9//! innermost frame's, which runs while the whole stack is still claimed —
10//! deep-copies it into a [`CrashSnapshot`] owned by the [`Runtime`]. The copy is
11//! stable because the collector is precise and non-moving (ADR-011): a `GcRef`
12//! copied into a snapshot keeps pointing at the same object.
13//!
14//! Copying eagerly at the first fault epilogue is ADR-033 decision 1. Reading
15//! the words lazily from the host would be possible and is rejected: values
16//! above `top` are in no arm of [`crate::roots::RuntimeRoots`], so a collection
17//! between the unwind and the read could free what they name.
18//!
19//! ADR-106 is what makes the eager copy *sound* rather than merely
20//! conventional. Values **below** `top` are the weak arm: every collection
21//! clears the debug slots whose objects it reclaimed, so at the moment
22//! [`copy_stack`] reads a slot, that slot holds a live object or `None`. Values
23//! **above** `top` are in no arm at all — nothing scans them, nothing clears
24//! them, and a popped frame's words are stale. The eager copy is what keeps the
25//! snapshot on the first side of that line, and the weak arm is what makes the
26//! first side mean something.
27//!
28//! GC rooting (the §19.10 acceptance criterion "GC retains all objects
29//! reachable from snapshots"): [`CrashSnapshot`] implements [`RootSet`],
30//! yielding every copied `DebugLocal.value` that is a
31//! [`DebugValue::Reference`](crate::debug::DebugValue) — a slot holding an
32//! elided box's scalar payload (ADR-120 part 2) names no object and roots
33//! nothing, and `DebugValue::reference` is where it drops out. Transitive
34//! reachability is the collector's job; the snapshot just pins the entry
35//! points. The host registers the snapshot as a root when collecting during the
36//! REPL/noninteractive render.
37//!
38//! Idempotency: a snapshot is taken at most once per fault. The
39//! [`SnapshotSlot`] guards with a `taken` flag; subsequent fault epilogues (the
40//! outer frames unwinding after the innermost already snapshotted) are no-ops.
41//! The slot is cleared at the start of each program run.
42
43use crate::abi::abi_guard;
44use crate::context::DebugLocal;
45use crate::debug::DebugFrameEntry;
46use crate::gc::GcRef;
47use crate::roots::RootSet;
48
49/// A deep copy of one debug frame + its locals, stable across the fault unwind.
50/// The `value` GcRefs point at the same non-moving objects the live frame did.
51#[derive(Debug)]
52pub struct SnapshotFrame {
53    /// The caller's index in [`CrashSnapshot::frames`], or `usize::MAX` for the
54    /// outermost frame.
55    pub parent: usize,
56    /// The function name (copied from the live frame's `'static` name pointer;
57    /// safe to keep as a raw pointer since the compiler embedded it `'static`).
58    pub func_name: *const u8,
59    pub func_name_len: u32,
60    /// The copied locals. `value` fields are the GC roots.
61    pub locals: Vec<DebugLocal>,
62    /// The function's source span `[start, end)` byte offsets (§9.3). Copied
63    /// from the live frame; `(0, 0)` means "unknown". The `source` REPL command
64    /// renders it.
65    pub source_span: (u32, u32),
66}
67
68/// A crash snapshot: the deep-copied frame chain + the fault kind that triggered
69/// it. Owned by the [`Runtime`] via [`SnapshotSlot`]; implements [`RootSet`] so
70/// the collector retains every snapshot-reachable object.
71#[derive(Debug)]
72pub struct CrashSnapshot {
73    /// The copied frames, in innermost-first order (frame 0 is the faulting
74    /// function; the last is `main`).
75    pub frames: Vec<SnapshotFrame>,
76    /// The fault kind that triggered the snapshot (§9.1). Set when taken.
77    pub fault_kind: crate::FaultKind,
78}
79
80impl Default for CrashSnapshot {
81    fn default() -> Self {
82        CrashSnapshot {
83            frames: Vec::new(),
84            fault_kind: crate::FaultKind::None,
85        }
86    }
87}
88
89impl CrashSnapshot {
90    /// A fresh, empty snapshot.
91    pub fn new() -> Self {
92        CrashSnapshot::default()
93    }
94
95    /// True iff no frames were captured.
96    pub fn is_empty(&self) -> bool {
97        self.frames.is_empty()
98    }
99
100    /// The number of frames in the chain (0 if not taken).
101    pub fn len(&self) -> usize {
102        self.frames.len()
103    }
104
105    /// The function name of frame `i` as a `&str`, or `<unknown>`.
106    ///
107    /// # Safety
108    /// The frame's `func_name` must be valid UTF-8 for `func_name_len` bytes
109    /// (the compiler guarantees this for embedded names).
110    pub unsafe fn frame_name(&self, i: usize) -> &str {
111        let f = &self.frames[i];
112        if f.func_name.is_null() || f.func_name_len == 0 {
113            return "<unknown>";
114        }
115        // SAFETY: caller upholds the UTF-8/len contract (compiler-embedded names).
116        unsafe {
117            std::str::from_utf8_unchecked(std::slice::from_raw_parts(
118                f.func_name,
119                f.func_name_len as usize,
120            ))
121        }
122    }
123}
124
125impl RootSet for CrashSnapshot {
126    fn push_roots(&self, out: &mut Vec<GcRef>) {
127        // Walk every copied local's value. A slot no value was ever spilled
128        // into is `None` and roots nothing — an absence the type carries, not a
129        // sentinel pointer to be compared against. A slot holding an elided
130        // box's scalar payload (ADR-120 part 2) roots nothing either, and
131        // `reference()` is where it drops out: a snapshot *is* a strong root
132        // set, so a scalar reaching this line would be a payload traced as an
133        // object.
134        for frame in &self.frames {
135            out.extend(
136                frame
137                    .locals
138                    .iter()
139                    .filter_map(|l| l.value.and_then(crate::debug::DebugValue::reference)),
140            );
141        }
142    }
143}
144
145/// A runtime-owned slot holding at most one [`CrashSnapshot`], with a `taken`
146/// guard for idempotent snapshotting across the multi-frame fault unwind.
147///
148/// Lives on [`crate::Runtime`] (address-stable); `clear` resets it before each
149/// program run so a stale snapshot does not leak into the next.
150#[derive(Debug, Default)]
151pub struct SnapshotSlot {
152    snapshot: Option<CrashSnapshot>,
153}
154
155impl SnapshotSlot {
156    /// A fresh, empty slot.
157    pub fn new() -> Self {
158        SnapshotSlot::default()
159    }
160
161    /// Clear any held snapshot (call before each program run).
162    pub fn clear(&mut self) {
163        self.snapshot = None;
164    }
165
166    /// Borrow the held snapshot, if any.
167    #[must_use]
168    pub fn get(&self) -> Option<&CrashSnapshot> {
169        self.snapshot.as_ref()
170    }
171
172    /// Take the held snapshot out of the slot (the host owns it after).
173    pub fn take(&mut self) -> Option<CrashSnapshot> {
174        self.snapshot.take()
175    }
176
177    /// True iff a snapshot is currently held.
178    pub fn is_set(&self) -> bool {
179        self.snapshot.is_some()
180    }
181}
182
183/// Deep-copy the claimed debug frames into a fresh [`CrashSnapshot`], recording
184/// the pending fault kind, and store it in the runtime's [`SnapshotSlot`] —
185/// **but only if no snapshot has been taken yet this run** (idempotency: the
186/// innermost fault epilogue runs first, while every frame is still claimed;
187/// outer frames unwinding later are no-ops).
188///
189/// Called from generated fault epilogues (and the stack-overflow epilogue)
190/// *before* the debug-stack pops. If the stack is empty (no debug frames were
191/// pushed, e.g. a host-side fault path), this is a no-op.
192///
193/// # Safety
194/// `ctx` must be live and wired.
195#[unsafe(no_mangle)]
196pub unsafe extern "C" fn praxis_snapshot_debug_chain(ctx: *mut crate::RuntimeContext) {
197    abi_guard!("praxis_snapshot_debug_chain", ctx, {
198        if ctx.is_null() {
199            return;
200        }
201        let slot_ptr = unsafe { (*ctx).crash_snapshot };
202        if slot_ptr.is_null() {
203            return;
204        }
205        // Idempotency: if a snapshot already exists this run, do nothing. The first
206        // (innermost) fault epilogue captures the whole stack; later frames skip.
207        // SAFETY: slot_ptr points at a live SnapshotSlot owned by the Runtime.
208        if unsafe { (*slot_ptr).is_set() } {
209            return;
210        }
211        let frames = unsafe { (*ctx).debug_frames };
212        if frames.is_null() {
213            return;
214        }
215        // SAFETY: a non-null `debug_frames` is the header of a live
216        // `DebugFrameStack` owned by the runtime that wired this context.
217        let entries = unsafe { (*frames).claimed() };
218        if entries.is_empty() {
219            return;
220        }
221        // SAFETY: every claimed entry was written by a prologue with a
222        // `'static` meta and the base of its own run of value slots, and no
223        // epilogue has run yet (this is called before the pops).
224        let snapshot = unsafe { copy_stack(entries) };
225        let kind = unsafe { crate::context::current_fault_kind(ctx) };
226        let mut s = CrashSnapshot::new();
227        s.fault_kind = kind;
228        s.frames = snapshot;
229        unsafe { (*slot_ptr).snapshot = Some(s) };
230    })
231}
232
233/// Deep-copy the debug chain **of a program that is still running** into a fresh
234/// snapshot, without touching the runtime's [`SnapshotSlot`].
235///
236/// This is [`praxis_snapshot_debug_chain`] with both of its fault-shaped
237/// properties removed, and each removal is the point:
238///
239/// - **No `taken` guard.** A fault snapshots once because the unwind calls it
240///   once per frame; a `:bp` marker stops every time control reaches it, and the
241///   tenth stop must show the tenth state.
242/// - **No slot, and no fault kind.** The result is handed straight to the host's
243///   handler and dropped when the stop ends, so it never becomes a root set and
244///   never has to be one — [`crate::breakpoint`]'s header states why the handler
245///   cannot collect underneath it. Writing it into the crash slot would be worse
246///   than unnecessary: that slot is a *fault's*, and filling it would make a
247///   later real fault find one already taken and skip its own.
248///
249/// The frames are ADR-106's weak arm at the moment they are read, so every
250/// reference copied out names a live object or is [`RECLAIMED_WORD`].
251///
252/// [`RECLAIMED_WORD`]: crate::debug::RECLAIMED_WORD
253///
254/// # Safety
255/// `ctx` must be live and wired, and every claimed debug frame entry must
256/// satisfy [`copy_stack`]'s contract — which every generated prologue
257/// establishes.
258pub(crate) unsafe fn copy_live_chain(ctx: *mut crate::RuntimeContext) -> CrashSnapshot {
259    let mut snapshot = CrashSnapshot::new();
260    if ctx.is_null() {
261        return snapshot;
262    }
263    let frames = unsafe { (*ctx).debug_frames };
264    if frames.is_null() {
265        return snapshot;
266    }
267    // SAFETY: a non-null `debug_frames` is the header of a live
268    // `DebugFrameStack` owned by the runtime that wired this context.
269    let entries = unsafe { (*frames).claimed() };
270    // SAFETY: every claimed entry was written by a prologue with a `'static`
271    // meta and the base of its own run of value slots, and this runs *between*
272    // instructions of the innermost frame — so no epilogue has popped anything.
273    snapshot.frames = unsafe { copy_stack(entries) };
274    snapshot
275}
276
277/// Deep-copy the claimed frame entries into a `Vec<SnapshotFrame>`,
278/// innermost-first. The `parent` index of each frame points at the next entry
279/// in the vec (so frame 0's parent is frame 1, etc.); the outermost frame's
280/// parent is `usize::MAX` (sentinel for "no parent").
281///
282/// `entries` is in push order — outermost first — so this walks it in reverse.
283/// The stack's order *is* the chain: it cannot be truncated or looped by a bad
284/// pointer.
285///
286/// A [`DebugLocal`] is reassembled here from the two halves ADR-104 keeps
287/// apart: the static half comes from the function's
288/// [`crate::debug::FunctionDebugMeta`] and the value from the call's own slot.
289/// That is what lets `SnapshotFrame`, `CrashSnapshot` and every consumer in
290/// `praxis-debugger` see one joined local.
291///
292/// # Safety
293/// Every entry's `meta` must point at a live `FunctionDebugMeta` whose `locals`
294/// array has `local_count` entries, and its `values` at that many value slots.
295unsafe fn copy_stack(entries: &[DebugFrameEntry]) -> Vec<SnapshotFrame> {
296    let mut out = Vec::with_capacity(entries.len());
297    for entry in entries.iter().rev() {
298        // SAFETY: the caller guarantees `meta` is live; a prologue writes it
299        // before anything that could fault, so null is unreachable here.
300        let Some(meta) = (unsafe { entry.meta.as_ref() }) else {
301            continue;
302        };
303        let count = meta.local_count as usize;
304        let locals: Vec<DebugLocal> = if count == 0 {
305            Vec::new()
306        } else {
307            // SAFETY: the caller guarantees both arrays hold `count` entries.
308            let metas = unsafe { std::slice::from_raw_parts(meta.locals, count) };
309            let values = unsafe { std::slice::from_raw_parts(entry.values, count) };
310            metas
311                .iter()
312                .zip(values)
313                .map(|(m, &word)| DebugLocal {
314                    source_name: m.source_name,
315                    name_len: m.name_len,
316                    symbol_id: m.symbol_id,
317                    descriptor: m.descriptor,
318                    // The zip *is* `read`'s precondition: slot `i` is decoded
319                    // under local `i`'s own `slot_kind` and no other's. A temp
320                    // whose box ADR-120 elided becomes a `DebugValue::Scalar`
321                    // here and is therefore not in `push_roots`' root set below
322                    // — correctly, since there is nothing to keep alive.
323                    // SAFETY: as above; the two arrays are index-parallel.
324                    value: unsafe { m.read(word) },
325                    type_id: m.type_id,
326                    kind: m.kind,
327                    span_start: m.span_start,
328                    span_end: m.span_end,
329                    callee_name: m.callee_name,
330                    callee_name_len: m.callee_name_len,
331                })
332                .collect()
333        };
334        out.push(SnapshotFrame {
335            // parent index is filled in the second pass below.
336            parent: usize::MAX,
337            func_name: meta.func_name,
338            func_name_len: meta.func_name_len,
339            source_span: (meta.span_start, meta.span_end),
340            locals,
341        });
342    }
343    // Fill in parent indices: frame i's parent is i+1 (the caller), except the
344    // outermost frame whose parent stays usize::MAX.
345    for i in 0..out.len().saturating_sub(1) {
346        out[i].parent = i + 1;
347    }
348    out
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::scalars::{INT, INT_PAYLOAD};
355    use crate::{LOCAL_KIND_USER, Runtime};
356
357    #[test]
358    fn empty_snapshot_roots_nothing() {
359        let s = CrashSnapshot::new();
360        let mut out = Vec::new();
361        s.push_roots(&mut out);
362        assert!(out.is_empty());
363        assert!(s.is_empty());
364    }
365
366    #[test]
367    fn snapshot_slot_clear_resets() {
368        let mut slot = SnapshotSlot::new();
369        assert!(!slot.is_set());
370        slot.snapshot = Some(CrashSnapshot::new());
371        assert!(slot.is_set());
372        slot.clear();
373        assert!(!slot.is_set());
374    }
375
376    #[test]
377    fn explicit_collection_preserves_values_held_by_a_crash_snapshot() {
378        let runtime = Runtime::new();
379        let value = runtime.heap().alloc_unpaced(INT_PAYLOAD, 42_i64);
380        let snapshot = CrashSnapshot {
381            frames: vec![SnapshotFrame {
382                parent: usize::MAX,
383                func_name: std::ptr::null(),
384                func_name_len: 0,
385                locals: vec![DebugLocal {
386                    callee_name: std::ptr::null(),
387                    callee_name_len: 0,
388                    source_name: std::ptr::null(),
389                    name_len: 0,
390                    symbol_id: 0,
391                    descriptor: &INT as *const _,
392                    value: Some(crate::debug::DebugValue::Reference(value)),
393                    type_id: 0,
394                    kind: LOCAL_KIND_USER,
395                    span_start: 0,
396                    span_end: 0,
397                }],
398                source_span: (0, 0),
399            }],
400            fault_kind: crate::FaultKind::None,
401        };
402
403        runtime.collect_with(&snapshot);
404
405        assert_eq!(runtime.heap().stats().live_count, 1);
406        assert_eq!(value.as_int(), 42);
407    }
408
409    /// A snapshot taken *out* of the runtime outlives it in both hosts: the CLI
410    /// moves the runtime into the `DebugSession` the `Repl` owns alongside the
411    /// snapshot, and the debugger REPL replaces its snapshot after a restart.
412    /// `Heap` finalizes live payloads on `Drop`, so that ordering is
413    /// load-bearing — the property this pins is that dropping the snapshot
414    /// after the runtime is *itself* harmless: a `CrashSnapshot` holds `GcRef`s
415    /// but has no `Drop` that dereferences one.
416    #[test]
417    fn a_snapshot_may_be_dropped_after_the_runtime_it_names() {
418        let snapshot = {
419            let runtime = Runtime::new();
420            let value = runtime.heap().alloc_unpaced(INT_PAYLOAD, 42_i64);
421            CrashSnapshot {
422                frames: vec![SnapshotFrame {
423                    parent: usize::MAX,
424                    func_name: std::ptr::null(),
425                    func_name_len: 0,
426                    locals: vec![DebugLocal {
427                        callee_name: std::ptr::null(),
428                        callee_name_len: 0,
429                        source_name: std::ptr::null(),
430                        name_len: 0,
431                        symbol_id: 0,
432                        descriptor: &INT as *const _,
433                        value: Some(crate::debug::DebugValue::Reference(value)),
434                        type_id: 0,
435                        kind: LOCAL_KIND_USER,
436                        span_start: 0,
437                        span_end: 0,
438                    }],
439                    source_span: (0, 0),
440                }],
441                fault_kind: crate::FaultKind::None,
442            }
443            // `runtime` — and its heap, which finalizes `value` — dies here.
444        };
445        // The frames are still readable as plain data; only the objects they
446        // *name* are gone. Reading `value.as_int()` here would be a
447        // use-after-free, and is deliberately not done.
448        assert_eq!(snapshot.len(), 1);
449        drop(snapshot);
450    }
451
452    /// ADR-106's rule, end to end at the level the snapshot is taken.
453    ///
454    /// The setup is what every Praxis function produces at a local's last use:
455    /// `RootSlots::dead` nulls the shadow slot and the debug slot keeps the
456    /// value, so between the two the debugger names an object no arm of the
457    /// root set reaches. A collection in that window reclaims it and the *next*
458    /// allocation of the same layout takes the block back — here as a `Float`,
459    /// since `Float`'s payload has `Int`'s size and alignment, so it lands on
460    /// the same rung of the ladder.
461    ///
462    /// **The reissue is the whole point.** Without the weak arm the snapshot
463    /// copies a `GcRef` that is now a live `Float` under a local whose static
464    /// descriptor says `Int`, and `impl RootSet for CrashSnapshot` then roots
465    /// it — a strong root, of the wrong type, into a `CrashSnapshot`. And no
466    /// filter applied *here* could have caught it: at this point the block is a
467    /// perfectly ordinary live object, indistinguishable from one the local
468    /// legitimately named. That is why the clear happens inside the collection.
469    #[test]
470    fn a_reissued_block_is_not_rendered_under_the_dead_locals_name() {
471        use crate::scalars::FLOAT_PAYLOAD;
472
473        let mut rt = Runtime::new();
474        // Past the interned small-`Int` range: an interned `Int` is an immortal
475        // no sweep touches, and this test needs a real allocation to die.
476        let dead = rt.heap().alloc_unpaced(INT_PAYLOAD, 9_999_i64);
477        let address = dead.as_ptr();
478        let mut ctx = Box::new(rt.context());
479
480        let name = b"xs";
481        let locals = [crate::DebugLocalMeta {
482            callee_name: std::ptr::null(),
483            callee_name_len: 0,
484            source_name: name.as_ptr(),
485            name_len: 2,
486            symbol_id: 1,
487            descriptor: &INT,
488            type_id: 1,
489            kind: LOCAL_KIND_USER,
490            span_start: 0,
491            span_end: 0,
492            slot_kind: crate::debug::DebugSlotKind::Reference,
493        }];
494        let meta = crate::FunctionDebugMeta {
495            func_name: b"main".as_ptr(),
496            func_name_len: 4,
497            local_count: 1,
498            locals: locals.as_ptr(),
499            span_start: 0,
500            span_end: 0,
501        };
502        // SAFETY: `ctx` is wired to `rt`, and `meta`/`locals` outlive the guard.
503        let mut guard = unsafe { crate::debug::push_frame(&mut *ctx, &meta) };
504        guard.set(0, dead);
505
506        rt.collect_now();
507
508        let reissued = rt.heap().alloc_unpaced(FLOAT_PAYLOAD, 2.5_f64);
509        assert_eq!(
510            reissued.as_ptr(),
511            address,
512            "this test only says anything if the dead local's block came back"
513        );
514
515        // SAFETY: `ctx` is live and wired; the frame is still claimed, which is
516        // the state a fault epilogue snapshots in.
517        unsafe { praxis_snapshot_debug_chain(&mut *ctx) };
518        drop(guard);
519
520        let snapshot = rt.take_crash_snapshot().expect("a frame was claimed");
521        assert_eq!(snapshot.len(), 1);
522        let local = &snapshot.frames[0].locals[0];
523        assert_eq!(
524            local.value,
525            Some(crate::debug::DebugValue::Reclaimed),
526            "the snapshot copied the reissued block under `xs`, whose static \
527             descriptor is Int — a `Float` rendered as an `Int`, and a strong \
528             root to it out of `CrashSnapshot::push_roots`"
529        );
530        // And it is the *collected* absence, not the unwritten one. `guard.set`
531        // above wrote this slot; a snapshot that reported `None` here would be
532        // saying the store never happened.
533        assert_ne!(local.value, None, "a written slot never reads as unwritten");
534
535        let mut out = Vec::new();
536        snapshot.push_roots(&mut out);
537        assert!(
538            out.is_empty(),
539            "an absence must root nothing; a dangling entry would have made \
540             the snapshot a strong root set for storage it does not own"
541        );
542        assert_eq!(reissued.descriptor().name, "Float");
543    }
544}