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}