Skip to main content

mlua_probe_core/debug/
engine.rs

1//! Debug session — the core coordinator.
2//!
3//! [`DebugSession`] attaches to a [`mlua::Lua`] instance and installs
4//! a debug hook.  A [`DebugController`] is used by frontends to send
5//! commands and receive events.
6//!
7//! # Error strategy
8//!
9//! Two distinct error types coexist because of mlua's callback constraint:
10//!
11//! | Context | Return type | Poison conversion |
12//! |---------|-------------|-------------------|
13//! | Hook callbacks (`set_hook`) | [`LuaResult`] | [`poison_to_lua`] → [`mlua::Error::runtime`] |
14//! | Public API ([`DebugSession`]) | `Result<_, DebugError>` | `From<PoisonError>` → [`DebugError::Internal`] |
15//!
16//! **Why two paths?**  [`Lua::set_hook`](mlua::Lua::set_hook) requires
17//! `Fn(&Lua, &Debug) -> Result<VmState>`, so hook internals must return
18//! [`mlua::Error`], not [`DebugError`].  Public methods return
19//! [`DebugError`] to keep frontends independent of mlua error types.
20//!
21//! `Error::runtime()` is chosen over `Error::external()` because a
22//! poisoned mutex is unrecoverable — no caller will downcast it — and
23//! `runtime()` avoids the `Arc<Box<dyn Error>>` indirection.
24//!
25//! # Mutex ordering
26//!
27//! All mutexes in [`SessionInner`] are acquired on the VM thread only
28//! (inside the hook callback).  The lock ordering is:
29//!
30//! 1. `cmd_rx` (held for the duration of the paused loop)
31//! 2. `step_mode` (short-lived)
32//! 3. `breakpoints` (short-lived)
33//!
34//! `step_mode` and `breakpoints` are never held simultaneously.
35//! `stop_on_entry` and `first_line_seen` are `AtomicBool` — lock-free.
36
37use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
38use std::sync::{mpsc, Arc, Mutex};
39
40use mlua::prelude::*;
41use mlua::{HookTriggers, VmState};
42
43use super::breakpoint::BreakpointRegistry;
44use super::controller::DebugController;
45use super::error::DebugError;
46use super::inspector;
47use super::source::SourceRegistry;
48use super::stepping;
49use super::types::{DebugCommand, DebugEvent, PauseReason, SessionState, StepMode};
50
51/// Convert a [`PoisonError`](std::sync::PoisonError) into [`mlua::Error`]
52/// for propagation from hook callbacks.
53///
54/// Used exclusively inside [`hook_callback`], [`enter_paused_loop`], and
55/// [`dispatch_command`] where the return type is constrained to
56/// [`LuaResult`] by [`Lua::set_hook`](mlua::Lua::set_hook).
57///
58/// See [module-level docs](self) for the rationale behind the two-path
59/// error strategy.
60fn poison_to_lua<T>(e: std::sync::PoisonError<T>) -> mlua::Error {
61    mlua::Error::runtime(format!("mutex poisoned: {e}"))
62}
63
64/// A debug session attached to a single `Lua` instance.
65///
66/// Create with [`DebugSession::new`], which also returns a
67/// [`DebugController`] for the frontend.
68///
69/// # Lifecycle
70///
71/// ```text
72/// new() → Idle → attach() → Running ⇄ Paused → Terminated
73/// ```
74///
75/// [`attach`](Self::attach) is a **one-shot** operation (per DAP
76/// semantics).  Calling it on a non-Idle session returns an error.
77/// To re-attach, create a new `DebugSession`.
78pub struct DebugSession {
79    inner: Arc<SessionInner>,
80}
81
82pub(crate) struct SessionInner {
83    pub cmd_rx: Mutex<mpsc::Receiver<DebugCommand>>,
84    /// Internal sender for `detach()` to unblock the paused loop.
85    pub cmd_tx_internal: mpsc::Sender<DebugCommand>,
86    pub evt_tx: mpsc::Sender<DebugEvent>,
87    pub state: Arc<AtomicU8>,
88    pub breakpoints: Arc<Mutex<BreakpointRegistry>>,
89    pub step_mode: Mutex<Option<StepMode>>,
90    pub call_depth: AtomicUsize,
91    pub sources: Mutex<SourceRegistry>,
92    /// Whether the engine should stop on the very first line.
93    pub stop_on_entry: AtomicBool,
94    /// Tracks whether we've seen the first line event.
95    pub first_line_seen: AtomicBool,
96    /// Set by [`DebugController::pause`] — checked on every line event.
97    pub pause_requested: Arc<AtomicBool>,
98    /// Fast-path flag: `true` when step mode is active.
99    ///
100    /// Updated by [`resume_execution`] (set) and [`enter_paused_loop`]
101    /// (clear).  Avoids mutex lock on `step_mode` in the hot path.
102    pub has_step_mode: AtomicBool,
103    /// Fast-path flag: `true` when the breakpoint registry is non-empty.
104    ///
105    /// Shared with [`DebugController`](super::controller::DebugController)
106    /// which updates the flag after add/remove operations.
107    pub has_active_breakpoints: Arc<AtomicBool>,
108}
109
110impl DebugSession {
111    /// Create a new debug session and its associated controller.
112    ///
113    /// The session is in `Idle` state until [`attach`](Self::attach) is
114    /// called.
115    pub fn new() -> (Self, DebugController) {
116        let (cmd_tx, cmd_rx) = mpsc::channel();
117        let (evt_tx, evt_rx) = mpsc::channel();
118        let state = Arc::new(AtomicU8::new(SessionState::Idle.to_u8()));
119
120        let breakpoints = Arc::new(Mutex::new(BreakpointRegistry::new()));
121        let pause_requested = Arc::new(AtomicBool::new(false));
122        let has_active_breakpoints = Arc::new(AtomicBool::new(false));
123
124        let inner = Arc::new(SessionInner {
125            cmd_rx: Mutex::new(cmd_rx),
126            cmd_tx_internal: cmd_tx.clone(),
127            evt_tx,
128            state: state.clone(),
129            breakpoints: breakpoints.clone(),
130            step_mode: Mutex::new(None),
131            call_depth: AtomicUsize::new(0),
132            sources: Mutex::new(SourceRegistry::new()),
133            stop_on_entry: AtomicBool::new(false),
134            first_line_seen: AtomicBool::new(false),
135            pause_requested: pause_requested.clone(),
136            has_step_mode: AtomicBool::new(false),
137            has_active_breakpoints: has_active_breakpoints.clone(),
138        });
139
140        let controller = DebugController::new(
141            cmd_tx,
142            evt_rx,
143            state,
144            breakpoints,
145            pause_requested,
146            has_active_breakpoints,
147        );
148
149        (Self { inner }, controller)
150    }
151
152    /// Install the debug hook on the given `Lua` instance.
153    ///
154    /// After this call, breakpoints and stepping become active.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the session is not in [`SessionState::Idle`]
159    /// (e.g. already attached or terminated).  Per DAP semantics,
160    /// attach/launch is a one-shot operation per session.
161    pub fn attach(&self, lua: &Lua) -> LuaResult<()> {
162        // Atomically transition Idle → Running.  Reject if not Idle.
163        let prev = self.inner.state.compare_exchange(
164            SessionState::Idle.to_u8(),
165            SessionState::Running.to_u8(),
166            Ordering::AcqRel,
167            Ordering::Acquire,
168        );
169        if prev.is_err() {
170            return Err(mlua::Error::runtime(
171                "attach failed: session is not Idle (already attached or terminated)",
172            ));
173        }
174
175        let inner = self.inner.clone();
176        let triggers = HookTriggers::EVERY_LINE | HookTriggers::ON_CALLS | HookTriggers::ON_RETURNS;
177
178        lua.set_hook(triggers, move |lua, debug| {
179            hook_callback(lua, debug, &inner)
180        })?;
181
182        Ok(())
183    }
184
185    /// Detach from the Lua instance and terminate the session.
186    ///
187    /// If the VM is paused (blocked in the command loop), this sends
188    /// a disconnect command to unblock it.  A [`DebugEvent::Terminated`]
189    /// event is emitted to notify the frontend.
190    ///
191    /// After detach, this session instance should be dropped.
192    /// To debug again, create a new `DebugSession`.
193    pub fn detach(&self, lua: &Lua) {
194        // Terminate the session (idempotent — only sends event once).
195        terminate(&self.inner, None, None);
196        // If the VM thread is blocked in the paused loop, unblock it.
197        let _ = self.inner.cmd_tx_internal.send(DebugCommand::Disconnect);
198        // Remove the hook to prevent further invocations.
199        lua.remove_hook();
200    }
201
202    /// Register source code so the engine can validate breakpoints
203    /// and display source context.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`DebugError::Internal`] if the source lock is poisoned
208    /// (another thread panicked while holding it).
209    pub fn register_source(&self, name: &str, content: &str) -> Result<(), DebugError> {
210        self.inner.sources.lock()?.register(name, content);
211        Ok(())
212    }
213
214    /// Set whether to pause on the first executable line.
215    ///
216    /// **Must be called before [`attach`](Self::attach)** to guarantee
217    /// the setting takes effect before the first hook fires.
218    pub fn set_stop_on_entry(&self, stop: bool) {
219        self.inner.stop_on_entry.store(stop, Ordering::Release);
220    }
221
222    /// Create a [`CompletionNotifier`] for signaling execution completion.
223    ///
224    /// Move the returned handle into the thread that runs Lua code.
225    /// Call [`CompletionNotifier::notify`] when execution finishes.
226    ///
227    /// # Example
228    ///
229    /// ```rust,ignore
230    /// let notifier = session.completion_notifier();
231    /// std::thread::spawn(move || {
232    ///     let result = lua.load("...").exec();
233    ///     notifier.notify(result.err().map(|e| e.to_string()));
234    /// });
235    /// ```
236    pub fn completion_notifier(&self) -> CompletionNotifier {
237        CompletionNotifier {
238            inner: self.inner.clone(),
239        }
240    }
241}
242
243/// A handle for notifying the session that Lua execution has completed.
244///
245/// Created by [`DebugSession::completion_notifier`].  Move this into the
246/// thread that runs Lua code so it can emit [`DebugEvent::Terminated`] when
247/// execution finishes (successfully or with an error).
248///
249/// # Idempotent
250///
251/// If the session was already terminated (e.g. via
252/// [`DebugController::disconnect`](super::controller::DebugController::disconnect)),
253/// [`notify`](Self::notify) is a no-op — no duplicate event is sent.
254pub struct CompletionNotifier {
255    inner: Arc<SessionInner>,
256}
257
258impl CompletionNotifier {
259    /// Signal that Lua execution has finished.
260    ///
261    /// Transitions to [`SessionState::Terminated`] and emits
262    /// [`DebugEvent::Terminated`].  Pass `Some(message)` if execution
263    /// failed; `None` for normal completion.
264    ///
265    /// **Note:** This method sets `result` to `None`.  Use
266    /// [`notify_with_result`](Self::notify_with_result) to also report
267    /// a successful return value.
268    pub fn notify(self, error: Option<String>) {
269        terminate(&self.inner, None, error);
270    }
271
272    /// Signal that Lua execution has finished, with an optional result.
273    ///
274    /// Like [`notify`](Self::notify), but additionally propagates a
275    /// successful return value through [`DebugEvent::Terminated::result`].
276    pub fn notify_with_result(self, result: Option<String>, error: Option<String>) {
277        terminate(&self.inner, result, error);
278    }
279}
280
281// Compile-time guarantee that CompletionNotifier can be moved to another thread.
282#[allow(dead_code)]
283const _: () = {
284    fn assert_send<T: Send>() {}
285    fn check() {
286        assert_send::<CompletionNotifier>();
287    }
288};
289
290// ─── Hook callback ──────────────────────────────────
291
292fn hook_callback(lua: &Lua, debug: &mlua::Debug<'_>, inner: &SessionInner) -> LuaResult<VmState> {
293    let event = debug.event();
294
295    match event {
296        mlua::DebugEvent::Call => {
297            inner.call_depth.fetch_add(1, Ordering::Relaxed);
298        }
299        mlua::DebugEvent::TailCall => {
300            // Tail call replaces the current frame — no new stack level.
301            //
302            // Lua 5.4 Reference Manual (lua_Hook):
303            //   "for a tail call; in this case, there will be no
304            //    corresponding return event."
305            //
306            // Because TAILCALL consumes the caller's frame and RET only
307            // fires once (for the tail-called function), incrementing
308            // here would permanently inflate call_depth.
309            //
310            // Example: `function a() return b() end; a()`
311            //   CALL(a) depth 0→1 | TAILCALL(b) depth 1→1 | RET(b) depth 1→0
312        }
313        mlua::DebugEvent::Ret => {
314            // Saturating sub to avoid underflow if Ret fires before
315            // the matching Call (can happen for C functions).
316            let _ = inner
317                .call_depth
318                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |d| {
319                    Some(d.saturating_sub(1))
320                });
321        }
322        mlua::DebugEvent::Line => {
323            // Skip processing if session has been terminated (e.g. after
324            // Disconnect).  Without this guard, a subsequent breakpoint
325            // hit would enter the paused loop and block forever because
326            // no frontend is sending commands.
327            if inner.state.load(Ordering::Acquire) == SessionState::Terminated.to_u8() {
328                return Ok(VmState::Continue);
329            }
330
331            // Check stop-on-entry (lock-free via AtomicBool).
332            // compare_exchange: if first_line_seen is false, set to true
333            // and check stop_on_entry.  Subsequent calls see true and skip.
334            let stop_entry = inner
335                .first_line_seen
336                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
337                .is_ok()
338                && inner.stop_on_entry.load(Ordering::Acquire);
339
340            // Check pause request from the controller (atomic swap consumes the flag).
341            let user_pause = inner.pause_requested.swap(false, Ordering::AcqRel);
342
343            // Fast path: skip source lookup and mutex locks when nothing
344            // to check.  All four conditions are atomic loads — no
345            // contention on the hot path.
346            let needs_check = stop_entry
347                || user_pause
348                || inner.has_step_mode.load(Ordering::Acquire)
349                || inner.has_active_breakpoints.load(Ordering::Acquire);
350
351            if needs_check {
352                let line = debug.current_line().unwrap_or(0);
353                let source = debug.source();
354                let source_name = source.source.as_deref().unwrap_or("<unknown>");
355
356                let call_depth = inner.call_depth.load(Ordering::Relaxed);
357                let step_mode = inner.step_mode.lock().map_err(poison_to_lua)?.clone();
358                let bp_registry = inner.breakpoints.lock().map_err(poison_to_lua)?;
359                let should_pause = stop_entry
360                    || user_pause
361                    || stepping::should_pause(
362                        &step_mode,
363                        &bp_registry,
364                        source_name,
365                        line,
366                        call_depth,
367                    );
368                drop(bp_registry);
369
370                if should_pause {
371                    enter_paused_loop(lua, inner, source_name, line, user_pause)?;
372                }
373            }
374        }
375        _ => {}
376    }
377
378    Ok(VmState::Continue)
379}
380
381// ─── Paused loop ────────────────────────────────────
382
383/// Set step mode, transition to `Running`, and emit `Continued`.
384fn resume_execution(inner: &SessionInner, step: Option<StepMode>) -> LuaResult<()> {
385    inner.has_step_mode.store(step.is_some(), Ordering::Release);
386    if let Some(mode) = step {
387        *inner.step_mode.lock().map_err(poison_to_lua)? = Some(mode);
388    }
389    inner
390        .state
391        .store(SessionState::Running.to_u8(), Ordering::Release);
392    let _ = inner.evt_tx.send(DebugEvent::Continued);
393    Ok(())
394}
395
396/// Transition to `Terminated` and emit the event.
397///
398/// Idempotent: if the session is already `Terminated`, the event is
399/// not sent again (prevents duplicate `Terminated` events when
400/// `detach()` races with `Disconnect`).
401fn terminate(inner: &SessionInner, result: Option<String>, error: Option<String>) {
402    let prev = inner
403        .state
404        .swap(SessionState::Terminated.to_u8(), Ordering::AcqRel);
405    if prev != SessionState::Terminated.to_u8() {
406        let _ = inner.evt_tx.send(DebugEvent::Terminated { result, error });
407    }
408}
409
410fn enter_paused_loop(
411    lua: &Lua,
412    inner: &SessionInner,
413    source: &str,
414    line: usize,
415    user_pause: bool,
416) -> LuaResult<()> {
417    // Determine pause reason (breakpoint > step > user_pause > entry).
418    //
419    // Lock ordering: breakpoints is acquired and released before
420    // step_mode to uphold the documented invariant that they are
421    // never held simultaneously.
422    let bp_id = {
423        let registry = inner.breakpoints.lock().map_err(poison_to_lua)?;
424        registry.find(source, line).map(|bp| bp.id)
425    };
426    let reason = if let Some(id) = bp_id {
427        PauseReason::Breakpoint(id)
428    } else if inner.step_mode.lock().map_err(poison_to_lua)?.is_some() {
429        PauseReason::Step
430    } else if user_pause {
431        PauseReason::UserPause
432    } else {
433        PauseReason::Entry
434    };
435
436    // Clear step mode when we actually pause.
437    *inner.step_mode.lock().map_err(poison_to_lua)? = None;
438    inner.has_step_mode.store(false, Ordering::Release);
439
440    // Update state.
441    inner.state.store(
442        SessionState::Paused(reason.clone()).to_u8(),
443        Ordering::Release,
444    );
445
446    // Collect stack trace for the event.
447    let stack = inspector::collect_stack_trace(lua, 0);
448
449    // Notify frontend.
450    let _ = inner.evt_tx.send(DebugEvent::Paused { reason, stack });
451
452    // Command dispatch loop — blocks the VM thread.
453    let cmd_rx = inner.cmd_rx.lock().map_err(poison_to_lua)?;
454    loop {
455        match cmd_rx.recv() {
456            Ok(cmd) => {
457                if dispatch_command(lua, inner, cmd)? {
458                    break;
459                }
460            }
461            Err(_) => {
462                terminate(inner, None, Some("frontend disconnected".into()));
463                break;
464            }
465        }
466    }
467
468    Ok(())
469}
470
471/// Handle a single command inside the paused loop.
472///
473/// Returns `Ok(true)` for resume/disconnect commands (caller breaks the loop).
474/// Returns `Ok(false)` for inspection/management commands (loop continues).
475fn dispatch_command(lua: &Lua, inner: &SessionInner, cmd: DebugCommand) -> LuaResult<bool> {
476    match cmd {
477        // ── Resume commands ──
478        DebugCommand::Continue => {
479            resume_execution(inner, None)?;
480        }
481        DebugCommand::StepInto => {
482            resume_execution(inner, Some(StepMode::Into))?;
483        }
484        DebugCommand::StepOver => {
485            let depth = inner.call_depth.load(Ordering::Relaxed);
486            resume_execution(inner, Some(StepMode::Over { start_depth: depth }))?;
487        }
488        DebugCommand::StepOut => {
489            let depth = inner.call_depth.load(Ordering::Relaxed);
490            resume_execution(inner, Some(StepMode::Out { start_depth: depth }))?;
491        }
492
493        // ── Inspection commands (VM thread) ──
494        DebugCommand::GetStackTrace { reply } => {
495            let _ = reply.send(inspector::collect_stack_trace(lua, 0));
496            return Ok(false);
497        }
498        DebugCommand::GetLocals { frame_id, reply } => {
499            let _ = reply.send(inspector::inspect_locals(lua, frame_id));
500            return Ok(false);
501        }
502        DebugCommand::GetUpvalues { frame_id, reply } => {
503            let _ = reply.send(inspector::inspect_upvalues(lua, frame_id));
504            return Ok(false);
505        }
506        DebugCommand::Evaluate {
507            expression,
508            frame_id,
509            reply,
510        } => {
511            let _ = reply.send(inspector::evaluate_expression(lua, &expression, frame_id));
512            return Ok(false);
513        }
514
515        // ── Session ──
516        DebugCommand::Disconnect => {
517            terminate(inner, None, None);
518        }
519    }
520
521    Ok(true)
522}