Skip to main content

sui_eval/
trace.rs

1//! Infinite recursion debugging tools for the tree-walker evaluator.
2//!
3//! Five integrated tools:
4//!
5//! 1. **Force chain capture** — always-on, captures the chain of thunk
6//!    forces leading to a blackhole cycle.
7//! 2. **Trace mode** (`SUI_TRACE_EVAL=1` or `=verbose`) — logs every
8//!    thunk force to stderr or a ring buffer.
9//! 3. **Max force depth** (`--max-force-depth N`) — caps the force
10//!    stack and reports early.
11//! 4. **Thunk stats** — extends `perf.rs` counters with thunk-specific
12//!    metrics (created, forced unique, max depth).
13//! 5. **Static cycle detection** — lives in the compiler; see
14//!    `sui-bytecode/src/compiler.rs`.
15
16use std::cell::{Cell, RefCell};
17use std::collections::VecDeque;
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20
21// ── Tool 1: Force Chain Capture ──────────────────────────────────
22
23/// A single entry on the force stack.
24#[derive(Debug, Clone)]
25pub struct ForceFrame {
26    /// File where the thunk was defined (if known).
27    pub defined_in: Option<PathBuf>,
28    /// Human-readable description (truncated source text).
29    pub description: String,
30    /// Unique identity of the thunk (pointer address).
31    pub thunk_id: usize,
32}
33
34/// The chain of forces that led to a cycle.
35#[derive(Debug, Clone)]
36pub struct ForceChain(pub Vec<ForceFrame>);
37
38thread_local! {
39    static FORCE_STACK: RefCell<Vec<ForceFrame>> = RefCell::new(Vec::new());
40}
41
42/// Push a frame onto the force stack. Called when a thunk begins forcing.
43pub fn push_force(frame: ForceFrame) {
44    FORCE_STACK.with(|s| {
45        s.borrow_mut().push(frame);
46        // Update thunk stats: track max depth.
47        let depth = s.borrow().len();
48        THUNK_MAX_FORCE_DEPTH.with(|m| {
49            if depth > m.get() as usize {
50                m.set(depth as u32);
51            }
52        });
53        THUNK_CURRENT_FORCE_DEPTH.with(|c| c.set(depth as u32));
54    });
55}
56
57/// Pop a frame from the force stack. Called when a thunk finishes forcing.
58pub fn pop_force() {
59    FORCE_STACK.with(|s| {
60        s.borrow_mut().pop();
61        let depth = s.borrow().len();
62        THUNK_CURRENT_FORCE_DEPTH.with(|c| c.set(depth as u32));
63    });
64}
65
66/// Diagnostic: is `thunk_id` present anywhere on the current force
67/// stack?  A `true` means the SAME thunk pointer is being re-entered
68/// (a genuine self-cycle); a `false` means the blackholed thunk is NOT
69/// on the stack (a re-created / distinct thunk — a sharing gap).
70pub fn force_stack_contains(thunk_id: usize) -> bool {
71    FORCE_STACK.with(|s| s.borrow().iter().any(|f| f.thunk_id == thunk_id))
72}
73
74/// Diagnostic: dump the whole force stack's thunk ids + files + descriptions.
75pub fn dump_force_stack_ids() {
76    FORCE_STACK.with(|s| {
77        let stack = s.borrow();
78        eprintln!("[SUI_DEBUG_CYCLE] force stack depth={}", stack.len());
79        for (i, f) in stack.iter().enumerate() {
80            let loc = f
81                .defined_in
82                .as_ref()
83                .map(|p| p.display().to_string())
84                .unwrap_or_else(|| "<eval>".into());
85            let d: String = f.description.chars().take(50).collect();
86            let d = d.replace('\n', " ");
87            eprintln!("[SUI_DEBUG_CYCLE]   [{i}] id={:#x} {loc}  ::  {d}", f.thunk_id);
88        }
89    });
90}
91
92/// Capture the cycle portion of the force stack starting from the
93/// frame whose `thunk_id` matches the blackholed thunk.
94pub fn capture_cycle(thunk_id: usize) -> ForceChain {
95    FORCE_STACK.with(|s| {
96        let stack = s.borrow();
97        let start = stack.iter().position(|f| f.thunk_id == thunk_id);
98        match start {
99            Some(idx) => ForceChain(stack[idx..].to_vec()),
100            None => ForceChain(stack.clone()),
101        }
102    })
103}
104
105impl std::fmt::Display for ForceChain {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        writeln!(f, "infinite recursion detected")?;
108        writeln!(f, "force chain ({} frames):", self.0.len())?;
109        // Dedup adjacent identical descriptions to keep the chain
110        // readable when the same expression text repeats (mutual
111        // recursion through a single call site).  Empty descriptions
112        // bypass dedup — they're the "cheap" non-tracing form where
113        // every frame is a distinct thunk so collapsing them would
114        // misrepresent the cycle.  Set `SUI_TRACE_EVAL=verbose` for
115        // the rich per-frame descriptions.
116        let mut prev_desc: Option<&str> = None;
117        let mut repeat = 0u32;
118        for (i, frame) in self.0.iter().enumerate() {
119            let has_desc = !frame.description.is_empty();
120            if has_desc && prev_desc == Some(&frame.description) {
121                repeat += 1;
122                continue;
123            }
124            if repeat > 0 {
125                writeln!(f, "    ... repeated {repeat} more times")?;
126                repeat = 0;
127            }
128            let loc = frame
129                .defined_in
130                .as_ref()
131                .map(|p| p.display().to_string())
132                .unwrap_or_else(|| "<eval>".into());
133            let arrow = if i == 0 { "\u{2192}" } else { "\u{2192}" };
134            let desc = if has_desc {
135                frame.description.as_str()
136            } else {
137                "<thunk>"
138            };
139            writeln!(f, "  {arrow} {desc} ({loc})")?;
140            prev_desc = if has_desc { Some(&frame.description) } else { None };
141        }
142        if repeat > 0 {
143            writeln!(f, "    ... repeated {repeat} more times")?;
144        }
145        if self.0.iter().any(|fr| fr.description.is_empty()) {
146            writeln!(
147                f,
148                "  hint: set SUI_TRACE_EVAL=verbose for per-frame source text"
149            )?;
150        }
151        Ok(())
152    }
153}
154
155// ── Tool 2: Trace Mode ──────────────────────────────────────────
156
157static TRACE_ENABLED: AtomicBool = AtomicBool::new(false);
158
159/// Whether trace mode is set to "verbose" (prints each force immediately)
160/// vs. ring-buffer mode (only dumps on error).
161static TRACE_VERBOSE: AtomicBool = AtomicBool::new(false);
162
163/// Initialize tracing from the `SUI_TRACE_EVAL` environment variable.
164///
165/// - Empty / unset: tracing disabled
166/// - `"1"` or `"verbose"`: verbose mode (each force printed to stderr)
167/// - Any other non-empty value: ring-buffer mode (dumped on error)
168pub fn init_trace() {
169    let mode = std::env::var("SUI_TRACE_EVAL").unwrap_or_default();
170    if mode.is_empty() {
171        TRACE_ENABLED.store(false, Ordering::Relaxed);
172        TRACE_VERBOSE.store(false, Ordering::Relaxed);
173    } else {
174        TRACE_ENABLED.store(true, Ordering::Relaxed);
175        TRACE_VERBOSE.store(mode == "1" || mode == "verbose", Ordering::Relaxed);
176    }
177}
178
179/// Whether any trace mode is active.
180#[inline(always)]
181pub fn trace_enabled() -> bool {
182    TRACE_ENABLED.load(Ordering::Relaxed)
183}
184
185thread_local! {
186    static TRACE_DEPTH: Cell<u32> = const { Cell::new(0) };
187    /// Ring buffer for non-verbose mode — only dump on error.
188    static RING_BUFFER: RefCell<VecDeque<String>> =
189        RefCell::new(VecDeque::with_capacity(256));
190}
191
192/// Log a force-enter event. In verbose mode, prints immediately.
193/// In ring-buffer mode, stores for later dump.
194pub fn trace_force_enter(file: Option<&Path>, desc: &str) {
195    if !trace_enabled() {
196        return;
197    }
198    let depth = TRACE_DEPTH.with(|d| {
199        let v = d.get();
200        d.set(v + 1);
201        v
202    });
203    let indent = "  ".repeat(depth as usize);
204    let loc = file
205        .map(|f| f.display().to_string())
206        .unwrap_or_default();
207    let msg = format!("[trace] {indent}force {loc} ({desc})");
208    if TRACE_VERBOSE.load(Ordering::Relaxed) {
209        eprintln!("{msg}");
210    }
211    RING_BUFFER.with(|rb| {
212        let mut rb = rb.borrow_mut();
213        if rb.len() >= 256 {
214            rb.pop_front();
215        }
216        rb.push_back(msg);
217    });
218}
219
220/// Log a force-exit event (decrements trace depth).
221pub fn trace_force_exit() {
222    if !trace_enabled() {
223        return;
224    }
225    TRACE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
226}
227
228/// Dump the last `n` ring-buffer entries to stderr (for inline diagnostics).
229pub fn dump_ring_tail(n: usize) {
230    RING_BUFFER.with(|rb| {
231        let rb = rb.borrow();
232        let start = rb.len().saturating_sub(n);
233        for line in rb.iter().skip(start) {
234            eprintln!("{line}");
235        }
236    });
237}
238
239/// Dump the trace ring buffer to stderr. Called on error paths.
240pub fn dump_trace_on_error() {
241    if !trace_enabled() {
242        return;
243    }
244    RING_BUFFER.with(|rb| {
245        let rb = rb.borrow();
246        if rb.is_empty() {
247            return;
248        }
249        eprintln!("[trace] last {} force operations:", rb.len());
250        for line in rb.iter() {
251            eprintln!("{line}");
252        }
253    });
254}
255
256// ── Tool 3: Max Force Depth ─────────────────────────────────────
257
258static MAX_FORCE_DEPTH: AtomicUsize = AtomicUsize::new(0);
259
260/// Set the maximum allowed force depth. 0 means no limit.
261pub fn set_max_force_depth(limit: usize) {
262    MAX_FORCE_DEPTH.store(limit, Ordering::Relaxed);
263}
264
265/// Check whether the current force depth exceeds the configured limit.
266/// Returns `Ok(())` if within bounds or no limit is set.
267pub fn check_force_depth() -> Result<(), String> {
268    let limit = MAX_FORCE_DEPTH.load(Ordering::Relaxed);
269    if limit == 0 {
270        return Ok(());
271    }
272    let depth = FORCE_STACK.with(|s| s.borrow().len());
273    if depth > limit {
274        Err(format!("force depth exceeded ({depth}/{limit})"))
275    } else {
276        Ok(())
277    }
278}
279
280// ── Tool 5: Thunk Stats (extends perf.rs) ───────────────────────
281
282thread_local! {
283    static THUNKS_CREATED: Cell<u64> = const { Cell::new(0) };
284    static THUNKS_FORCED_UNIQUE: Cell<u64> = const { Cell::new(0) };
285    static THUNK_MAX_FORCE_DEPTH: Cell<u32> = const { Cell::new(0) };
286    static THUNK_CURRENT_FORCE_DEPTH: Cell<u32> = const { Cell::new(0) };
287    /// Cumulative nanoseconds spent inside the overlay flatten-build closure
288    /// (`NixAttrs::as_flat` cache-miss path). Diagnostic only; gated on
289    /// `perf::enabled()`.
290    static OVERLAY_FLATTEN_NANOS: Cell<u128> = const { Cell::new(0) };
291    /// Cumulative nanoseconds spent inside `NixAttrs::sorted_entries`
292    /// (resolve + sort for attrNames/keys/iter/values). Diagnostic only.
293    static SORTED_ENTRIES_NANOS: Cell<u128> = const { Cell::new(0) };
294    /// Cumulative nanoseconds spent inside `referenced_idents` (Storm A —
295    /// the self/mutual-recursion detection subtree walk). Diagnostic only;
296    /// gated on `perf::enabled()`.
297    static SELF_REC_WALK_NANOS: Cell<u128> = const { Cell::new(0) };
298}
299
300/// Add `nanos` to the cumulative overlay-flatten-build timer.
301#[inline(always)]
302pub fn add_overlay_flatten_nanos(nanos: u128) {
303    if crate::perf::enabled() {
304        OVERLAY_FLATTEN_NANOS.with(|c| c.set(c.get() + nanos));
305    }
306}
307
308/// Read the cumulative overlay-flatten-build nanoseconds.
309pub fn get_overlay_flatten_nanos() -> u128 {
310    OVERLAY_FLATTEN_NANOS.with(Cell::get)
311}
312
313/// Add `nanos` to the cumulative sorted_entries timer.
314#[inline(always)]
315pub fn add_sorted_entries_nanos(nanos: u128) {
316    if crate::perf::enabled() {
317        SORTED_ENTRIES_NANOS.with(|c| c.set(c.get() + nanos));
318    }
319}
320
321/// Read the cumulative sorted_entries nanoseconds.
322pub fn get_sorted_entries_nanos() -> u128 {
323    SORTED_ENTRIES_NANOS.with(Cell::get)
324}
325
326/// Add `nanos` to the cumulative Storm-A (`referenced_idents`) timer.
327#[inline(always)]
328pub fn add_self_rec_walk_nanos(nanos: u128) {
329    if crate::perf::enabled() {
330        SELF_REC_WALK_NANOS.with(|c| c.set(c.get() + nanos));
331    }
332}
333
334/// Read the cumulative Storm-A (`referenced_idents`) nanoseconds.
335pub fn get_self_rec_walk_nanos() -> u128 {
336    SELF_REC_WALK_NANOS.with(Cell::get)
337}
338
339// ── M2 scratch: maybe_thunk `_`-arm expr-kind histogram ──────────
340// Byte-neutral (gated on perf::enabled): counts which rnix expr kind
341// each maybe_thunk fall-through thunk wraps, so the 369K `_`-arm
342// thunks can be traced to a kind and the byte-safe-elidable subset
343// (constant Str, Paren, already-value List) separated from the
344// laziness-critical subset (Select, Apply, If, With).
345thread_local! {
346    static MAYBE_OTHER_KINDS: RefCell<std::collections::BTreeMap<&'static str, u64>> =
347        RefCell::new(std::collections::BTreeMap::new());
348}
349
350#[inline(always)]
351pub fn inc_maybe_other_kind(kind: &'static str) {
352    if crate::perf::enabled() {
353        MAYBE_OTHER_KINDS.with(|m| *m.borrow_mut().entry(kind).or_insert(0) += 1);
354    }
355}
356
357pub fn report_maybe_other_kinds() {
358    if !crate::perf::enabled() {
359        return;
360    }
361    MAYBE_OTHER_KINDS.with(|m| {
362        let m = m.borrow();
363        if m.is_empty() {
364            return;
365        }
366        let mut rows: Vec<(&&'static str, &u64)> = m.iter().collect();
367        rows.sort_by(|a, b| b.1.cmp(a.1));
368        eprintln!("--- maybe_thunk `_`-arm by expr kind ---");
369        for (k, v) in rows {
370            eprintln!("  {k:<20} {v}");
371        }
372    });
373}
374
375/// Increment the thunks-created counter.
376#[inline(always)]
377pub fn inc_thunks_created() {
378    if crate::perf::enabled() {
379        THUNKS_CREATED.with(|c| c.set(c.get() + 1));
380    }
381}
382
383/// Increment the thunks-forced-unique counter.
384#[inline(always)]
385pub fn inc_thunks_forced_unique() {
386    if crate::perf::enabled() {
387        THUNKS_FORCED_UNIQUE.with(|c| c.set(c.get() + 1));
388    }
389}
390
391/// Get current force depth (debug).
392pub fn current_force_depth() -> u32 {
393    THUNK_CURRENT_FORCE_DEPTH.with(Cell::get)
394}
395
396/// Get thunks created count (for progress snapshots).
397pub fn get_thunks_created() -> u64 {
398    THUNKS_CREATED.with(Cell::get)
399}
400
401/// Get thunks forced count (for progress snapshots).
402pub fn get_thunks_forced() -> u64 {
403    THUNKS_FORCED_UNIQUE.with(Cell::get)
404}
405
406/// Zero the thunk-creation/force counters. Used by `perf::reset` /
407/// `perf::with_scope` to establish a clean measurement window.
408pub fn reset_thunk_stats() {
409    THUNKS_CREATED.with(|c| c.set(0));
410    THUNKS_FORCED_UNIQUE.with(|c| c.set(0));
411    THUNK_MAX_FORCE_DEPTH.with(|c| c.set(0));
412    OVERLAY_FLATTEN_NANOS.with(|c| c.set(0));
413    SORTED_ENTRIES_NANOS.with(|c| c.set(0));
414    SELF_REC_WALK_NANOS.with(|c| c.set(0));
415}
416
417/// Report thunk stats to stderr (called from `perf::report`).
418pub fn report_thunk_stats() {
419    if !crate::perf::enabled() {
420        return;
421    }
422    let created = THUNKS_CREATED.with(Cell::get);
423    let forced = THUNKS_FORCED_UNIQUE.with(Cell::get);
424    let max_depth = THUNK_MAX_FORCE_DEPTH.with(Cell::get);
425    eprintln!("thunks_created: {created}");
426    eprintln!("thunks_forced:  {forced}");
427    eprintln!("max_force_depth: {max_depth}");
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    // ── Force chain capture ─────────────────────────────────
435
436    #[test]
437    fn force_chain_display_empty() {
438        let chain = ForceChain(vec![]);
439        let s = chain.to_string();
440        assert!(s.contains("0 frames"));
441    }
442
443    #[test]
444    fn force_chain_display_single() {
445        let chain = ForceChain(vec![ForceFrame {
446            defined_in: Some(PathBuf::from("/test.nix")),
447            description: "x".into(),
448            thunk_id: 1,
449        }]);
450        let s = chain.to_string();
451        assert!(s.contains("1 frames"));
452        assert!(s.contains("/test.nix"));
453        assert!(s.contains("x"));
454    }
455
456    #[test]
457    fn force_chain_display_empty_descriptions_show_one_per_frame() {
458        // Empty descriptions (cheap non-tracing path) bypass dedup so
459        // the cycle length isn't visually collapsed to "repeated".
460        let frames: Vec<ForceFrame> = (0..3)
461            .map(|i| ForceFrame {
462                defined_in: Some(PathBuf::from(format!("/m{i}.nix"))),
463                description: String::new(),
464                thunk_id: i,
465            })
466            .collect();
467        let s = ForceChain(frames).to_string();
468        assert!(s.contains("3 frames"));
469        assert_eq!(s.matches("<thunk>").count(), 3);
470        assert!(s.contains("SUI_TRACE_EVAL=verbose"));
471    }
472
473    #[test]
474    fn force_chain_display_repeated_frames() {
475        let chain = ForceChain(vec![
476            ForceFrame {
477                defined_in: None,
478                description: "x".into(),
479                thunk_id: 1,
480            },
481            ForceFrame {
482                defined_in: None,
483                description: "x".into(),
484                thunk_id: 2,
485            },
486            ForceFrame {
487                defined_in: None,
488                description: "x".into(),
489                thunk_id: 3,
490            },
491            ForceFrame {
492                defined_in: None,
493                description: "y".into(),
494                thunk_id: 4,
495            },
496        ]);
497        let s = chain.to_string();
498        assert!(s.contains("repeated 2 more times"));
499        assert!(s.contains("y"));
500    }
501
502    #[test]
503    fn force_chain_display_eval_location() {
504        let chain = ForceChain(vec![ForceFrame {
505            defined_in: None,
506            description: "z".into(),
507            thunk_id: 1,
508        }]);
509        let s = chain.to_string();
510        assert!(s.contains("<eval>"));
511    }
512
513    #[test]
514    fn push_pop_force_stack() {
515        // Clear the thread-local stack first.
516        FORCE_STACK.with(|s| s.borrow_mut().clear());
517        push_force(ForceFrame {
518            defined_in: None,
519            description: "a".into(),
520            thunk_id: 100,
521        });
522        push_force(ForceFrame {
523            defined_in: None,
524            description: "b".into(),
525            thunk_id: 200,
526        });
527        let chain = capture_cycle(100);
528        assert_eq!(chain.0.len(), 2);
529        assert_eq!(chain.0[0].thunk_id, 100);
530        pop_force();
531        pop_force();
532    }
533
534    #[test]
535    fn capture_cycle_with_unknown_id() {
536        FORCE_STACK.with(|s| s.borrow_mut().clear());
537        push_force(ForceFrame {
538            defined_in: None,
539            description: "a".into(),
540            thunk_id: 10,
541        });
542        // Capture with an ID not on the stack returns the whole stack.
543        let chain = capture_cycle(999);
544        assert_eq!(chain.0.len(), 1);
545        pop_force();
546    }
547
548    // ── Trace mode ──────────────────────────────────────────
549
550    #[test]
551    fn trace_disabled_by_default() {
552        // After init with no env var, trace should be off.
553        // (Cannot reliably test env var setting in parallel tests,
554        // so just verify the function is callable.)
555        let _ = trace_enabled();
556    }
557
558    #[test]
559    fn trace_force_enter_exit_no_panic() {
560        // Ensure enter/exit don't panic even when trace is off.
561        trace_force_enter(None, "test");
562        trace_force_exit();
563    }
564
565    // ── Max force depth ─────────────────────────────────────
566
567    #[test]
568    fn check_force_depth_logic() {
569        // Test all depth-limit scenarios in a single test to avoid
570        // AtomicUsize races between parallel tests.
571        FORCE_STACK.with(|s| s.borrow_mut().clear());
572
573        // No limit — always OK.
574        set_max_force_depth(0);
575        assert!(check_force_depth().is_ok());
576
577        // Within limit — OK.
578        set_max_force_depth(10);
579        push_force(ForceFrame {
580            defined_in: None,
581            description: "a".into(),
582            thunk_id: 1,
583        });
584        assert!(check_force_depth().is_ok());
585
586        // Exceeded — 2 items with limit 1.
587        set_max_force_depth(1);
588        push_force(ForceFrame {
589            defined_in: None,
590            description: "b".into(),
591            thunk_id: 2,
592        });
593        let result = check_force_depth();
594        assert!(result.is_err());
595        assert!(result.unwrap_err().contains("force depth exceeded"));
596
597        // Cleanup.
598        pop_force();
599        pop_force();
600        set_max_force_depth(0);
601    }
602
603    // ── Thunk stats ─────────────────────────────────────────
604
605    #[test]
606    fn thunk_stats_increment() {
607        // Just verify the functions don't panic.
608        inc_thunks_created();
609        inc_thunks_forced_unique();
610    }
611
612    // ── Integration: force chain with eval ───────────────────
613
614    #[test]
615    fn force_chain_captures_self_reference() {
616        let result = crate::eval::eval("let x = x; in x");
617        assert!(result.is_err());
618        let msg = result.unwrap_err().to_string();
619        assert!(
620            msg.contains("infinite recursion")
621                || msg.contains("force chain")
622                || msg.contains("blackhole"),
623            "expected infinite recursion error, got: {msg}"
624        );
625    }
626
627    #[test]
628    fn force_chain_captures_mutual_recursion() {
629        let result = crate::eval::eval("let a = b; b = a; in a");
630        assert!(result.is_err());
631        let msg = result.unwrap_err().to_string();
632        assert!(
633            msg.contains("infinite recursion")
634                || msg.contains("force chain")
635                || msg.contains("blackhole"),
636            "expected infinite recursion error, got: {msg}"
637        );
638    }
639
640    #[test]
641    fn force_chain_captures_rec_self_reference() {
642        let result = crate::eval::eval("rec { x = x; }.x");
643        assert!(result.is_err());
644        let msg = result.unwrap_err().to_string();
645        assert!(
646            msg.contains("infinite recursion")
647                || msg.contains("force chain")
648                || msg.contains("blackhole"),
649            "expected infinite recursion error, got: {msg}"
650        );
651    }
652}