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    /// Re-entrancy guard: `true` while evaluating a breakpoint condition.
109    ///
110    /// Prevents the hook from firing again (via `lua_pcallk` inside the
111    /// condition evaluation) and potentially deadlocking or pausing
112    /// inside the condition code.
113    pub evaluating_condition: AtomicBool,
114}
115
116impl DebugSession {
117    /// Create a new debug session and its associated controller.
118    ///
119    /// The session is in `Idle` state until [`attach`](Self::attach) is
120    /// called.
121    pub fn new() -> (Self, DebugController) {
122        let (cmd_tx, cmd_rx) = mpsc::channel();
123        let (evt_tx, evt_rx) = mpsc::channel();
124        let state = Arc::new(AtomicU8::new(SessionState::Idle.to_u8()));
125
126        let breakpoints = Arc::new(Mutex::new(BreakpointRegistry::new()));
127        let pause_requested = Arc::new(AtomicBool::new(false));
128        let has_active_breakpoints = Arc::new(AtomicBool::new(false));
129
130        let inner = Arc::new(SessionInner {
131            cmd_rx: Mutex::new(cmd_rx),
132            cmd_tx_internal: cmd_tx.clone(),
133            evt_tx,
134            state: state.clone(),
135            breakpoints: breakpoints.clone(),
136            step_mode: Mutex::new(None),
137            call_depth: AtomicUsize::new(0),
138            sources: Mutex::new(SourceRegistry::new()),
139            stop_on_entry: AtomicBool::new(false),
140            first_line_seen: AtomicBool::new(false),
141            pause_requested: pause_requested.clone(),
142            has_step_mode: AtomicBool::new(false),
143            has_active_breakpoints: has_active_breakpoints.clone(),
144            evaluating_condition: AtomicBool::new(false),
145        });
146
147        let controller = DebugController::new(
148            cmd_tx,
149            evt_rx,
150            state,
151            breakpoints,
152            pause_requested,
153            has_active_breakpoints,
154        );
155
156        (Self { inner }, controller)
157    }
158
159    /// Install the debug hook on the given `Lua` instance.
160    ///
161    /// After this call, breakpoints and stepping become active.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the session is not in [`SessionState::Idle`]
166    /// (e.g. already attached or terminated).  Per DAP semantics,
167    /// attach/launch is a one-shot operation per session.
168    pub fn attach(&self, lua: &Lua) -> LuaResult<()> {
169        // Atomically transition Idle → Running.  Reject if not Idle.
170        let prev = self.inner.state.compare_exchange(
171            SessionState::Idle.to_u8(),
172            SessionState::Running.to_u8(),
173            Ordering::AcqRel,
174            Ordering::Acquire,
175        );
176        if prev.is_err() {
177            return Err(mlua::Error::runtime(
178                "attach failed: session is not Idle (already attached or terminated)",
179            ));
180        }
181
182        let inner = self.inner.clone();
183        let triggers = HookTriggers::EVERY_LINE | HookTriggers::ON_CALLS | HookTriggers::ON_RETURNS;
184
185        lua.set_hook(triggers, move |lua, debug| {
186            hook_callback(lua, debug, &inner)
187        })?;
188
189        Ok(())
190    }
191
192    /// Detach from the Lua instance and terminate the session.
193    ///
194    /// If the VM is paused (blocked in the command loop), this sends
195    /// a disconnect command to unblock it.  A [`DebugEvent::Terminated`]
196    /// event is emitted to notify the frontend.
197    ///
198    /// After detach, this session instance should be dropped.
199    /// To debug again, create a new `DebugSession`.
200    pub fn detach(&self, lua: &Lua) {
201        // Terminate the session (idempotent — only sends event once).
202        terminate(&self.inner, None, None);
203        // If the VM thread is blocked in the paused loop, unblock it.
204        let _ = self.inner.cmd_tx_internal.send(DebugCommand::Disconnect);
205        // Remove the hook to prevent further invocations.
206        lua.remove_hook();
207    }
208
209    /// Register source code so the engine can validate breakpoints
210    /// and display source context.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`DebugError::Internal`] if the source lock is poisoned
215    /// (another thread panicked while holding it).
216    pub fn register_source(&self, name: &str, content: &str) -> Result<(), DebugError> {
217        self.inner.sources.lock()?.register(name, content);
218        Ok(())
219    }
220
221    /// Set whether to pause on the first executable line.
222    ///
223    /// **Must be called before [`attach`](Self::attach)** to guarantee
224    /// the setting takes effect before the first hook fires.
225    pub fn set_stop_on_entry(&self, stop: bool) {
226        self.inner.stop_on_entry.store(stop, Ordering::Release);
227    }
228
229    /// Create a [`CompletionNotifier`] for signaling execution completion.
230    ///
231    /// Move the returned handle into the thread that runs Lua code.
232    /// Call [`CompletionNotifier::notify`] when execution finishes.
233    ///
234    /// # Example
235    ///
236    /// ```rust,ignore
237    /// let notifier = session.completion_notifier();
238    /// std::thread::spawn(move || {
239    ///     let result = lua.load("...").exec();
240    ///     notifier.notify(result.err().map(|e| e.to_string()));
241    /// });
242    /// ```
243    pub fn completion_notifier(&self) -> CompletionNotifier {
244        CompletionNotifier {
245            inner: self.inner.clone(),
246        }
247    }
248}
249
250/// A handle for notifying the session that Lua execution has completed.
251///
252/// Created by [`DebugSession::completion_notifier`].  Move this into the
253/// thread that runs Lua code so it can emit [`DebugEvent::Terminated`] when
254/// execution finishes (successfully or with an error).
255///
256/// # Idempotent
257///
258/// If the session was already terminated (e.g. via
259/// [`DebugController::disconnect`](super::controller::DebugController::disconnect)),
260/// [`notify`](Self::notify) is a no-op — no duplicate event is sent.
261pub struct CompletionNotifier {
262    inner: Arc<SessionInner>,
263}
264
265impl CompletionNotifier {
266    /// Signal that Lua execution has finished.
267    ///
268    /// Transitions to [`SessionState::Terminated`] and emits
269    /// [`DebugEvent::Terminated`].  Pass `Some(message)` if execution
270    /// failed; `None` for normal completion.
271    ///
272    /// **Note:** This method sets `result` to `None`.  Use
273    /// [`notify_with_result`](Self::notify_with_result) to also report
274    /// a successful return value.
275    pub fn notify(self, error: Option<String>) {
276        terminate(&self.inner, None, error);
277    }
278
279    /// Signal that Lua execution has finished, with an optional result.
280    ///
281    /// Like [`notify`](Self::notify), but additionally propagates a
282    /// successful return value through [`DebugEvent::Terminated::result`].
283    pub fn notify_with_result(self, result: Option<String>, error: Option<String>) {
284        terminate(&self.inner, result, error);
285    }
286}
287
288// Compile-time guarantee that CompletionNotifier can be moved to another thread.
289#[allow(dead_code)]
290const _: () = {
291    fn assert_send<T: Send>() {}
292    fn check() {
293        assert_send::<CompletionNotifier>();
294    }
295};
296
297// ─── Hook callback ──────────────────────────────────
298
299fn hook_callback(lua: &Lua, debug: &mlua::Debug<'_>, inner: &SessionInner) -> LuaResult<VmState> {
300    let event = debug.event();
301
302    match event {
303        mlua::DebugEvent::Call => {
304            inner.call_depth.fetch_add(1, Ordering::Relaxed);
305        }
306        mlua::DebugEvent::TailCall => {
307            // Tail call replaces the current frame — no new stack level.
308            //
309            // Lua 5.4 Reference Manual (lua_Hook):
310            //   "for a tail call; in this case, there will be no
311            //    corresponding return event."
312            //
313            // Because TAILCALL consumes the caller's frame and RET only
314            // fires once (for the tail-called function), incrementing
315            // here would permanently inflate call_depth.
316            //
317            // Example: `function a() return b() end; a()`
318            //   CALL(a) depth 0→1 | TAILCALL(b) depth 1→1 | RET(b) depth 1→0
319        }
320        mlua::DebugEvent::Ret => {
321            // Saturating sub to avoid underflow if Ret fires before
322            // the matching Call (can happen for C functions).
323            let _ = inner
324                .call_depth
325                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |d| {
326                    Some(d.saturating_sub(1))
327                });
328        }
329        mlua::DebugEvent::Line => {
330            // Skip processing if session has been terminated (e.g. after
331            // Disconnect).  Without this guard, a subsequent breakpoint
332            // hit would enter the paused loop and block forever because
333            // no frontend is sending commands.
334            if inner.state.load(Ordering::Acquire) == SessionState::Terminated.to_u8() {
335                return Ok(VmState::Continue);
336            }
337
338            // Re-entrancy guard: skip the hook while evaluating a
339            // breakpoint condition.  lua_pcallk inside evaluate_condition
340            // fires line events for the condition code — without this
341            // guard we'd deadlock or pause inside the condition.
342            if inner.evaluating_condition.load(Ordering::Acquire) {
343                return Ok(VmState::Continue);
344            }
345
346            // Check stop-on-entry (lock-free via AtomicBool).
347            // compare_exchange: if first_line_seen is false, set to true
348            // and check stop_on_entry.  Subsequent calls see true and skip.
349            let stop_entry = inner
350                .first_line_seen
351                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
352                .is_ok()
353                && inner.stop_on_entry.load(Ordering::Acquire);
354
355            // Check pause request from the controller (atomic swap consumes the flag).
356            let user_pause = inner.pause_requested.swap(false, Ordering::AcqRel);
357
358            // Fast path: skip source lookup and mutex locks when nothing
359            // to check.  All four conditions are atomic loads — no
360            // contention on the hot path.
361            let needs_check = stop_entry
362                || user_pause
363                || inner.has_step_mode.load(Ordering::Acquire)
364                || inner.has_active_breakpoints.load(Ordering::Acquire);
365
366            if needs_check {
367                let line = debug.current_line().unwrap_or(0);
368                let source = debug.source();
369                let source_name = source.source.as_deref().unwrap_or("<unknown>");
370
371                let call_depth = inner.call_depth.load(Ordering::Relaxed);
372                let step_mode = inner.step_mode.lock().map_err(poison_to_lua)?.clone();
373
374                // Step mode check (pure logic, no Lua interaction).
375                let step_pauses = stepping::step_triggers(&step_mode, call_depth);
376
377                // Breakpoint check with condition evaluation.
378                // Extract ID + condition, then drop the lock before
379                // evaluating (evaluation fires line events that
380                // re-enter this hook).
381                let bp_registry = inner.breakpoints.lock().map_err(poison_to_lua)?;
382                let bp_info = bp_registry
383                    .find(source_name, line)
384                    .filter(|bp| bp.enabled)
385                    .map(|bp| (bp.id, bp.condition.clone()));
386                drop(bp_registry);
387
388                let bp_hit_id = match bp_info {
389                    Some((id, Some(cond))) => {
390                        inner.evaluating_condition.store(true, Ordering::Release);
391                        // SAFETY: We are on the VM thread inside
392                        // the hook.  Level 0 = the function that
393                        // triggered the line event.
394                        let result = lua.exec_raw_lua(|raw| unsafe {
395                            super::ffi::evaluate_condition(raw.state(), &cond, 0)
396                        });
397                        inner.evaluating_condition.store(false, Ordering::Release);
398                        if result {
399                            Some(id)
400                        } else {
401                            None
402                        }
403                    }
404                    Some((id, None)) => Some(id),
405                    None => None,
406                };
407
408                // Determine pause reason with priority:
409                // Breakpoint > Step > UserPause > Entry.
410                let reason = if let Some(id) = bp_hit_id {
411                    Some(PauseReason::Breakpoint(id))
412                } else if step_pauses {
413                    Some(PauseReason::Step)
414                } else if user_pause {
415                    Some(PauseReason::UserPause)
416                } else if stop_entry {
417                    Some(PauseReason::Entry)
418                } else {
419                    None
420                };
421
422                if let Some(reason) = reason {
423                    enter_paused_loop(lua, inner, reason)?;
424                }
425            }
426        }
427        _ => {}
428    }
429
430    Ok(VmState::Continue)
431}
432
433// ─── Paused loop ────────────────────────────────────
434
435/// Set step mode, transition to `Running`, and emit `Continued`.
436fn resume_execution(inner: &SessionInner, step: Option<StepMode>) -> LuaResult<()> {
437    inner.has_step_mode.store(step.is_some(), Ordering::Release);
438    if let Some(mode) = step {
439        *inner.step_mode.lock().map_err(poison_to_lua)? = Some(mode);
440    }
441    inner
442        .state
443        .store(SessionState::Running.to_u8(), Ordering::Release);
444    let _ = inner.evt_tx.send(DebugEvent::Continued);
445    Ok(())
446}
447
448/// Transition to `Terminated` and emit the event.
449///
450/// Idempotent: if the session is already `Terminated`, the event is
451/// not sent again (prevents duplicate `Terminated` events when
452/// `detach()` races with `Disconnect`).
453fn terminate(inner: &SessionInner, result: Option<String>, error: Option<String>) {
454    let prev = inner
455        .state
456        .swap(SessionState::Terminated.to_u8(), Ordering::AcqRel);
457    if prev != SessionState::Terminated.to_u8() {
458        let _ = inner.evt_tx.send(DebugEvent::Terminated { result, error });
459    }
460}
461
462fn enter_paused_loop(lua: &Lua, inner: &SessionInner, reason: PauseReason) -> LuaResult<()> {
463    // Clear step mode when we actually pause.
464    *inner.step_mode.lock().map_err(poison_to_lua)? = None;
465    inner.has_step_mode.store(false, Ordering::Release);
466
467    // Update state.
468    inner.state.store(
469        SessionState::Paused(reason.clone()).to_u8(),
470        Ordering::Release,
471    );
472
473    // Collect stack trace for the event.
474    let stack = inspector::collect_stack_trace(lua, 0);
475
476    // Notify frontend.
477    let _ = inner.evt_tx.send(DebugEvent::Paused { reason, stack });
478
479    // Command dispatch loop — blocks the VM thread.
480    let cmd_rx = inner.cmd_rx.lock().map_err(poison_to_lua)?;
481    loop {
482        match cmd_rx.recv() {
483            Ok(cmd) => {
484                if dispatch_command(lua, inner, cmd)? {
485                    break;
486                }
487            }
488            Err(_) => {
489                terminate(inner, None, Some("frontend disconnected".into()));
490                break;
491            }
492        }
493    }
494
495    Ok(())
496}
497
498/// Handle a single command inside the paused loop.
499///
500/// Returns `Ok(true)` for resume/disconnect commands (caller breaks the loop).
501/// Returns `Ok(false)` for inspection/management commands (loop continues).
502fn dispatch_command(lua: &Lua, inner: &SessionInner, cmd: DebugCommand) -> LuaResult<bool> {
503    match cmd {
504        // ── Resume commands ──
505        DebugCommand::Continue => {
506            resume_execution(inner, None)?;
507        }
508        DebugCommand::StepInto => {
509            resume_execution(inner, Some(StepMode::Into))?;
510        }
511        DebugCommand::StepOver => {
512            let depth = inner.call_depth.load(Ordering::Relaxed);
513            resume_execution(inner, Some(StepMode::Over { start_depth: depth }))?;
514        }
515        DebugCommand::StepOut => {
516            let depth = inner.call_depth.load(Ordering::Relaxed);
517            resume_execution(inner, Some(StepMode::Out { start_depth: depth }))?;
518        }
519
520        // ── Inspection commands (VM thread) ──
521        DebugCommand::GetStackTrace { reply } => {
522            let _ = reply.send(inspector::collect_stack_trace(lua, 0));
523            return Ok(false);
524        }
525        DebugCommand::GetLocals { frame_id, reply } => {
526            let _ = reply.send(inspector::inspect_locals(lua, frame_id));
527            return Ok(false);
528        }
529        DebugCommand::GetUpvalues { frame_id, reply } => {
530            let _ = reply.send(inspector::inspect_upvalues(lua, frame_id));
531            return Ok(false);
532        }
533        DebugCommand::Evaluate {
534            expression,
535            frame_id,
536            reply,
537        } => {
538            let _ = reply.send(inspector::evaluate_expression(lua, &expression, frame_id));
539            return Ok(false);
540        }
541
542        // ── Session ──
543        DebugCommand::Disconnect => {
544            terminate(inner, None, None);
545        }
546    }
547
548    Ok(true)
549}