Skip to main content

mlua_probe_core/debug/
controller.rs

1//! Frontend-facing API for interacting with a debug session.
2//!
3//! [`DebugController`] is `Clone + Send + Sync` and can be shared
4//! across threads.  All methods are blocking.
5//!
6//! # Two access patterns
7//!
8//! The controller uses two distinct mechanisms depending on the
9//! operation type:
10//!
11//! - **Breakpoint management** — operates directly on the shared
12//!   [`BreakpointRegistry`] via `Arc<Mutex<…>>`.  Works in any session
13//!   state (Running, Paused, etc.) because DAP clients can set
14//!   breakpoints at any time.
15//!
16//! - **Inspection & execution control** — sends a [`DebugCommand`]
17//!   through the `mpsc` command channel.  These are dispatched on the
18//!   **VM thread** inside the paused loop, which is required because
19//!   `lua_getlocal` and friends are not thread-safe.
20
21use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
22use std::sync::{mpsc, Arc, Mutex};
23use std::time::Duration;
24
25use super::breakpoint::{Breakpoint, BreakpointRegistry};
26use super::error::DebugError;
27use super::types::*;
28
29/// Controls a [`DebugSession`](super::engine::DebugSession) from a
30/// frontend (MCP server, DAP adapter, Web console, …).
31///
32/// Thread-safe: `Clone + Send + Sync`.
33#[derive(Clone)]
34pub struct DebugController {
35    cmd_tx: mpsc::Sender<DebugCommand>,
36    evt_rx: Arc<Mutex<mpsc::Receiver<DebugEvent>>>,
37    state: Arc<AtomicU8>,
38    breakpoints: Arc<Mutex<BreakpointRegistry>>,
39    pause_requested: Arc<AtomicBool>,
40    /// Shared with [`SessionInner`](super::engine::SessionInner) —
41    /// updated after breakpoint add/remove for fast-path checking.
42    has_active_breakpoints: Arc<AtomicBool>,
43}
44
45impl DebugController {
46    pub(crate) fn new(
47        cmd_tx: mpsc::Sender<DebugCommand>,
48        evt_rx: mpsc::Receiver<DebugEvent>,
49        state: Arc<AtomicU8>,
50        breakpoints: Arc<Mutex<BreakpointRegistry>>,
51        pause_requested: Arc<AtomicBool>,
52        has_active_breakpoints: Arc<AtomicBool>,
53    ) -> Self {
54        Self {
55            cmd_tx,
56            evt_rx: Arc::new(Mutex::new(evt_rx)),
57            state,
58            breakpoints,
59            pause_requested,
60            has_active_breakpoints,
61        }
62    }
63
64    // ── State query ─────────────────────────────────
65
66    /// Current session state (non-blocking, coarse-grained).
67    ///
68    /// Returns whether the VM is Idle, Running, Paused, or Terminated.
69    /// **`PauseReason` is not preserved** in the atomic representation —
70    /// use [`wait_event`](Self::wait_event) to obtain the precise reason
71    /// from [`DebugEvent::Paused`].
72    pub fn state(&self) -> SessionState {
73        SessionState::from_u8(self.state.load(Ordering::Acquire))
74    }
75
76    /// Block until the next event arrives from the engine.
77    ///
78    /// This is the **authoritative source** for session events.  In
79    /// particular, [`DebugEvent::Paused`] carries the precise
80    /// [`PauseReason`] that [`state()`](Self::state) does not preserve.
81    pub fn wait_event(&self) -> Result<DebugEvent, DebugError> {
82        let rx = self.evt_rx.lock()?;
83        rx.recv().map_err(|_| {
84            DebugError::SessionClosed("event receive failed: engine disconnected".into())
85        })
86    }
87
88    /// Try to receive an event without blocking.
89    pub fn try_event(&self) -> Result<Option<DebugEvent>, DebugError> {
90        let rx = self.evt_rx.lock()?;
91        Ok(rx.try_recv().ok())
92    }
93
94    /// Block until the next event arrives, with a timeout.
95    ///
96    /// Returns `Ok(None)` if the timeout elapses without an event.
97    pub fn wait_event_timeout(&self, timeout: Duration) -> Result<Option<DebugEvent>, DebugError> {
98        let rx = self.evt_rx.lock()?;
99        match rx.recv_timeout(timeout) {
100            Ok(evt) => Ok(Some(evt)),
101            Err(mpsc::RecvTimeoutError::Timeout) => Ok(None),
102            Err(mpsc::RecvTimeoutError::Disconnected) => Err(DebugError::SessionClosed(
103                "event receive failed: engine disconnected".into(),
104            )),
105        }
106    }
107
108    // ── Breakpoint management ───────────────────────
109    //
110    // Direct `Arc<Mutex<BreakpointRegistry>>` access — NOT through the
111    // command channel.  This is intentional: DAP clients (e.g. VSCode)
112    // send setBreakpoints at any time, including while the VM is
113    // Running.  The command channel is only dispatched inside the
114    // paused loop, so it cannot serve Running-state BP requests.
115    //
116    // The hook reads the same registry via `breakpoints.lock()` on the
117    // VM thread.  Mutex serialises access — no race conditions.
118
119    /// Set a breakpoint.  Returns the assigned ID.
120    ///
121    /// Works in **any** session state (Idle, Running, Paused,
122    /// Terminated) — the breakpoint registry is shared via
123    /// `Arc<Mutex<…>>` and accessed directly, not through the command
124    /// channel.
125    pub fn set_breakpoint(
126        &self,
127        source: &str,
128        line: usize,
129        condition: Option<&str>,
130    ) -> Result<BreakpointId, DebugError> {
131        let id =
132            self.breakpoints
133                .lock()?
134                .add(source.to_string(), line, condition.map(String::from))?;
135        self.has_active_breakpoints.store(true, Ordering::Release);
136        Ok(id)
137    }
138
139    /// Remove a breakpoint by ID.
140    pub fn remove_breakpoint(&self, id: BreakpointId) -> Result<bool, DebugError> {
141        let mut reg = self.breakpoints.lock()?;
142        let removed = reg.remove(id);
143        if removed && reg.is_empty() {
144            self.has_active_breakpoints.store(false, Ordering::Release);
145        }
146        Ok(removed)
147    }
148
149    /// List all breakpoints.
150    pub fn list_breakpoints(&self) -> Result<Vec<Breakpoint>, DebugError> {
151        Ok(self.breakpoints.lock()?.list())
152    }
153
154    // ── Execution control ───────────────────────────
155    //
156    // Resume commands send DebugCommand variants through the mpsc channel.
157    // The commands are dispatched on the VM thread inside the paused
158    // loop.  Sending while Running is harmless (the command queues
159    // until the next pause) but has no immediate effect.
160    //
161    // `pause()` is the exception: it sets an atomic flag that the hook
162    // checks on every line event, making it effective while Running.
163
164    /// Resume execution.
165    pub fn continue_execution(&self) -> Result<(), DebugError> {
166        self.cmd_tx.send(DebugCommand::Continue)?;
167        Ok(())
168    }
169
170    /// Step into the next line (descend into calls).
171    pub fn step_into(&self) -> Result<(), DebugError> {
172        self.cmd_tx.send(DebugCommand::StepInto)?;
173        Ok(())
174    }
175
176    /// Step to the next line at the same or shallower call depth.
177    pub fn step_over(&self) -> Result<(), DebugError> {
178        self.cmd_tx.send(DebugCommand::StepOver)?;
179        Ok(())
180    }
181
182    /// Step out of the current function.
183    pub fn step_out(&self) -> Result<(), DebugError> {
184        self.cmd_tx.send(DebugCommand::StepOut)?;
185        Ok(())
186    }
187
188    /// Request the VM to pause at the next opportunity.
189    ///
190    /// Sets an atomic flag that is checked on every Lua line event.
191    /// Effective in any state — if the VM is Running, it will pause
192    /// at the next executed line.
193    pub fn pause(&self) -> Result<(), DebugError> {
194        self.pause_requested.store(true, Ordering::Release);
195        Ok(())
196    }
197
198    // ── Inspection (only valid while paused) ────────
199    //
200    // These send a DebugCommand with a one-shot reply channel.
201    // The engine executes the inspection on the **VM thread** (required
202    // because lua_getlocal / lua_getupvalue are not thread-safe) and
203    // sends the result back through the reply channel.
204
205    /// Verify the session is in [`Paused`](SessionState::Paused) state.
206    ///
207    /// Returns [`DebugError::InvalidState`] if the VM is not paused,
208    /// preventing callers from blocking indefinitely on a reply that
209    /// would never arrive.
210    fn ensure_paused(&self) -> Result<(), DebugError> {
211        match self.state() {
212            SessionState::Paused(_) => Ok(()),
213            other => Err(DebugError::InvalidState(format!(
214                "inspection requires Paused state, current: {other:?}"
215            ))),
216        }
217    }
218
219    /// Get the current call stack.
220    pub fn get_stack_trace(&self) -> Result<Vec<StackFrame>, DebugError> {
221        self.ensure_paused()?;
222        let (tx, rx) = mpsc::channel();
223        self.cmd_tx
224            .send(DebugCommand::GetStackTrace { reply: tx })?;
225        rx.recv().map_err(|_| {
226            DebugError::SessionClosed("stack trace reply lost: engine disconnected".into())
227        })
228    }
229
230    /// Get local variables at a stack frame.
231    pub fn get_locals(&self, frame_id: usize) -> Result<Vec<Variable>, DebugError> {
232        self.ensure_paused()?;
233        let (tx, rx) = mpsc::channel();
234        self.cmd_tx.send(DebugCommand::GetLocals {
235            frame_id,
236            reply: tx,
237        })?;
238        rx.recv().map_err(|_| {
239            DebugError::SessionClosed("get_locals reply lost: engine disconnected".into())
240        })
241    }
242
243    /// Get upvalues at a stack frame.
244    pub fn get_upvalues(&self, frame_id: usize) -> Result<Vec<Variable>, DebugError> {
245        self.ensure_paused()?;
246        let (tx, rx) = mpsc::channel();
247        self.cmd_tx.send(DebugCommand::GetUpvalues {
248            frame_id,
249            reply: tx,
250        })?;
251        rx.recv().map_err(|_| {
252            DebugError::SessionClosed("get_upvalues reply lost: engine disconnected".into())
253        })
254    }
255
256    /// Evaluate a Lua expression while paused.
257    ///
258    /// **Phase 1 limitation:** `frame_id` is accepted but ignored —
259    /// evaluation runs in the global scope only.  Frame-scoped
260    /// evaluation (locals + upvalues as env) is planned for Phase 2.
261    pub fn evaluate(
262        &self,
263        expression: &str,
264        frame_id: Option<usize>,
265    ) -> Result<String, DebugError> {
266        self.ensure_paused()?;
267        let (tx, rx) = mpsc::channel();
268        self.cmd_tx.send(DebugCommand::Evaluate {
269            expression: expression.to_string(),
270            frame_id,
271            reply: tx,
272        })?;
273        rx.recv()
274            .map_err(|_| {
275                DebugError::SessionClosed("evaluate reply lost: engine disconnected".into())
276            })?
277            .map_err(DebugError::EvalError)
278    }
279
280    /// Disconnect from the session.
281    pub fn disconnect(&self) -> Result<(), DebugError> {
282        self.cmd_tx.send(DebugCommand::Disconnect)?;
283        Ok(())
284    }
285}
286
287// Compile-time guarantee that DebugController can be shared across threads.
288#[allow(dead_code)]
289const _: () = {
290    fn assert_send_sync<T: Send + Sync>() {}
291    fn check() {
292        assert_send_sync::<DebugController>();
293    }
294};