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}