Skip to main content

sema_eval/
debug_session.rs

1//! Debug-session awareness for the tree-walking evaluator.
2//!
3//! Code pulled in via `(load ...)` / `(import ...)` is evaluated on the
4//! tree-walker, which does not participate in the VM's debug loop. As a result,
5//! breakpoints set in dynamically loaded/imported files never hit, silently.
6//!
7//! To avoid that being a silent surprise, the DAP server marks a debug session
8//! as active for the duration of a debugged run. When `load`/`import` runs while
9//! a session is active, the evaluator emits a single, clear warning (routed
10//! through `sema_core::write_stderr`, which the DAP server captures into an
11//! `Output` event) noting the limitation. The warning fires at most once per
12//! session so a program that loads many files does not spam the debug console.
13
14use std::cell::Cell;
15
16thread_local! {
17    /// Whether a VM debug session is currently active on this thread.
18    static DEBUG_SESSION_ACTIVE: Cell<bool> = const { Cell::new(false) };
19    /// Whether the "loaded/imported code bypasses the debugger" warning has
20    /// already been emitted for the current session.
21    static WARNED_LOAD_BYPASS: Cell<bool> = const { Cell::new(false) };
22}
23
24/// Mark a debug session as active or inactive on the current thread.
25///
26/// Setting this to `true` also resets the one-time warning latch so the warning
27/// can fire once per session. The DAP server calls this around `execute_debug`.
28pub fn set_debug_session_active(active: bool) {
29    DEBUG_SESSION_ACTIVE.with(|c| c.set(active));
30    if active {
31        WARNED_LOAD_BYPASS.with(|c| c.set(false));
32    }
33}
34
35/// Whether a debug session is active on the current thread.
36pub fn is_debug_session_active() -> bool {
37    DEBUG_SESSION_ACTIVE.with(|c| c.get())
38}
39
40/// Emit the "dynamically loaded/imported code bypasses the debugger" warning,
41/// but only when a debug session is active and only once per session.
42///
43/// `form` is the special-form name (`"load"` or `"import"`) and `path` is the
44/// path being loaded, used to make the message actionable.
45pub fn warn_load_bypass_once(form: &str, path: &str) {
46    if !is_debug_session_active() {
47        return;
48    }
49    let already = WARNED_LOAD_BYPASS.with(|c| c.replace(true));
50    if already {
51        return;
52    }
53    sema_core::write_stderr(&format!(
54        "Debugger: code reached via ({form} \"{path}\") is not stepped by the \
55         debugger (it runs outside the attached debug session), so breakpoints \
56         set in dynamically loaded or imported files are not hit. Stepping the \
57         main program is unaffected. (This warning is shown once per debug \
58         session.)\n"
59    ));
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use std::sync::{Arc, Mutex};
66
67    /// Capture stderr-hook output for the duration of `f`.
68    fn capture_stderr(f: impl FnOnce()) -> String {
69        let buf = Arc::new(Mutex::new(String::new()));
70        let buf_hook = buf.clone();
71        sema_core::set_stderr_hook(Some(Box::new(move |s: &str| {
72            buf_hook.lock().unwrap().push_str(s);
73        })));
74        f();
75        sema_core::set_stderr_hook(None);
76        let out = buf.lock().unwrap().clone();
77        out
78    }
79
80    #[test]
81    fn no_warning_when_session_inactive() {
82        set_debug_session_active(false);
83        let out = capture_stderr(|| {
84            warn_load_bypass_once("load", "helpers.sema");
85        });
86        assert!(out.is_empty(), "should not warn outside a debug session");
87    }
88
89    #[test]
90    fn warns_once_per_session() {
91        set_debug_session_active(true);
92        let out = capture_stderr(|| {
93            warn_load_bypass_once("load", "helpers.sema");
94            warn_load_bypass_once("import", "other.sema");
95        });
96        assert!(out.contains("not stepped by the debugger"));
97        assert!(out.contains("helpers.sema"));
98        // Second call in the same session is suppressed.
99        assert!(!out.contains("other.sema"));
100        // Re-activating the session resets the latch so a new run warns again.
101        set_debug_session_active(true);
102        let out2 = capture_stderr(|| {
103            warn_load_bypass_once("import", "again.sema");
104        });
105        assert!(out2.contains("again.sema"));
106        set_debug_session_active(false);
107    }
108}