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}