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};