Skip to content

Commit d85971a

Browse files
committed
feat(luau): expose native breakpoint and step debug API
Wrap Luau's debug C API so embedders can build line-precise debuggers: lua_breakpoint, lua_singlestep, lua_getlocal/lua_setlocal, and the debugbreak/debugstep callbacks. The callbacks mirror set_interrupt and can return VmState::Yield to suspend the running coroutine. New surface (all gated on the luau feature): - Lua::set_debug_break / set_debug_step / set_single_step and their removers - Function::set_breakpoint - Debug::get_local / set_local / locals The debug callbacks need their own trampoline instead of callback_error_ext: the latter reserves its WrappedFailure at the running frame's base, which shifts the paused function's registers and makes lua_getlocal read the wrong slots. debug_callback reserves at the top of the stack instead.
1 parent 15fb63b commit d85971a

7 files changed

Lines changed: 434 additions & 1 deletion

File tree

src/debug.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use ffi::{lua_Debug, lua_State};
1212
use crate::function::Function;
1313
use crate::state::RawLua;
1414
use crate::util::{StackGuard, assert_stack, linenumber_to_usize, ptr_to_lossy_str, ptr_to_str};
15+
#[cfg(feature = "luau")]
16+
use crate::{error::Result, value::Value};
1517

1618
/// Contains information about currently executing Lua code.
1719
///
@@ -205,6 +207,59 @@ impl<'a> Debug<'a> {
205207
stack
206208
}
207209
}
210+
211+
/// Reads local variable `index` (1-based) in this activation record, returning its name and
212+
/// current value, or `None` once `index` is past the last visible local (wraps `lua_getlocal`).
213+
///
214+
/// Luau keeps locals reachable here even though its sandbox removes `debug.getlocal`, so this is
215+
/// the way to inspect locals from a [`Lua::set_debug_break`]/[`Lua::set_debug_step`] callback.
216+
///
217+
/// [`Lua::set_debug_break`]: crate::Lua::set_debug_break
218+
/// [`Lua::set_debug_step`]: crate::Lua::set_debug_step
219+
#[cfg(any(feature = "luau", doc))]
220+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
221+
pub fn get_local(&self, index: usize) -> Option<(String, Value)> {
222+
unsafe {
223+
let _sg = StackGuard::new(self.state);
224+
assert_stack(self.state, 1);
225+
226+
// `lua_getlocal` pushes the value only when a local exists; otherwise it returns null.
227+
let name = ptr_to_lossy_str(ffi::lua_getlocal(self.state, self.level, index as c_int))?;
228+
Some((name.into_owned(), self.lua.pop_value()))
229+
}
230+
}
231+
232+
/// Assigns `value` to local variable `index` (1-based) in this record, returning `true` if a
233+
/// local with that index exists (wraps `lua_setlocal`).
234+
#[cfg(any(feature = "luau", doc))]
235+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
236+
pub fn set_local(&self, index: usize, value: Value) -> Result<bool> {
237+
unsafe {
238+
// `lua_setlocal` may leave the value on the stack when `index` is out of range; the
239+
// `StackGuard` restores the top in every case.
240+
let _sg = StackGuard::new(self.state);
241+
assert_stack(self.state, 1);
242+
243+
self.lua.push_value(&value)?;
244+
let name = ffi::lua_setlocal(self.state, self.level, index as c_int);
245+
Ok(!name.is_null())
246+
}
247+
}
248+
249+
/// Collects every readable local in this record as `(name, value)` pairs, in index order.
250+
///
251+
/// Convenience wrapper over [`Debug::get_local`].
252+
#[cfg(any(feature = "luau", doc))]
253+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
254+
pub fn locals(&self) -> Vec<(String, Value)> {
255+
let mut locals = Vec::new();
256+
let mut index = 1;
257+
while let Some(local) = self.get_local(index) {
258+
locals.push(local);
259+
index += 1;
260+
}
261+
locals
262+
}
208263
}
209264

210265
/// Represents a specific event that triggered the hook.

src/function.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,28 @@ impl Function {
582582
}
583583
}
584584

585+
/// Sets or clears a breakpoint on `line` of this Luau function (wraps `lua_breakpoint`).
586+
///
587+
/// Luau snaps the breakpoint to the next executable line, so the returned value is the line it
588+
/// was actually placed on (which may differ from `line`), or `None` if no executable line was
589+
/// found. Pair this with [`Lua::set_debug_break`] to observe the hit.
590+
///
591+
/// [`Lua::set_debug_break`]: crate::Lua::set_debug_break
592+
#[cfg(any(feature = "luau", doc))]
593+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
594+
pub fn set_breakpoint(&self, line: u32, enabled: bool) -> Option<u32> {
595+
let lua = self.0.lua.lock();
596+
let state = lua.state();
597+
unsafe {
598+
let _sg = StackGuard::new(state);
599+
assert_stack(state, 1);
600+
601+
lua.push_ref(&self.0);
602+
let actual = ffi::lua_breakpoint(state, -1, line as c_int, enabled as c_int);
603+
linenumber_to_usize(actual).map(|line| line as u32)
604+
}
605+
}
606+
585607
/// Converts this function to a generic C pointer.
586608
///
587609
/// There is no way to convert the pointer back to its original value.

src/state.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub(crate) use extra::ExtraData;
5252
#[doc(hidden)]
5353
pub use raw::RawLua;
5454
pub(crate) use util::callback_error_ext;
55+
#[cfg(feature = "luau")]
56+
pub(crate) use util::debug_callback;
5557

5658
/// Top level Lua struct which represents an instance of Lua VM.
5759
pub struct Lua {
@@ -842,6 +844,92 @@ impl Lua {
842844
}
843845
}
844846

847+
/// Sets the callback Luau invokes when a breakpoint (set via [`Function::set_breakpoint`]) is
848+
/// hit.
849+
///
850+
/// The callback receives a [`Debug`] for the paused frame, so it can read the current line and
851+
/// inspect locals. Returning [`VmState::Yield`] suspends the running coroutine at the
852+
/// breakpoint (the yield happens only at yieldable points, exactly as [`Lua::set_interrupt`]),
853+
/// which is what makes a non-blocking, single-threaded debugger possible.
854+
///
855+
/// Note: Luau re-evaluates the breakpoint when the coroutine is resumed, so the callback fires
856+
/// again on the same line. Return [`VmState::Continue`] on resume to step past it (a debugger
857+
/// typically yields on the first hit and continues once the user resumes).
858+
///
859+
/// [`Function::set_breakpoint`]: crate::Function::set_breakpoint
860+
#[cfg(any(feature = "luau", doc))]
861+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
862+
pub fn set_debug_break<F>(&self, callback: F)
863+
where
864+
F: Fn(&Lua, &Debug) -> Result<VmState> + MaybeSend + 'static,
865+
{
866+
unsafe extern "C-unwind" fn debug_break_proc(state: *mut ffi::lua_State, ar: *mut ffi::lua_Debug) {
867+
debug_callback(state, ar, |extra| (*extra).debug_break_callback.clone());
868+
}
869+
870+
let lua = self.lock();
871+
unsafe {
872+
(*lua.extra.get()).debug_break_callback = Some(XRc::new(callback));
873+
(*ffi::lua_callbacks(lua.main_state())).debugbreak = Some(debug_break_proc);
874+
}
875+
}
876+
877+
/// Sets the per-line single-step callback, invoked for each line while single-stepping is
878+
/// enabled via [`Lua::set_single_step`].
879+
///
880+
/// Like [`Lua::set_debug_break`], the callback may return [`VmState::Yield`] to suspend the
881+
/// running coroutine.
882+
#[cfg(any(feature = "luau", doc))]
883+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
884+
pub fn set_debug_step<F>(&self, callback: F)
885+
where
886+
F: Fn(&Lua, &Debug) -> Result<VmState> + MaybeSend + 'static,
887+
{
888+
unsafe extern "C-unwind" fn debug_step_proc(state: *mut ffi::lua_State, ar: *mut ffi::lua_Debug) {
889+
debug_callback(state, ar, |extra| (*extra).debug_step_callback.clone());
890+
}
891+
892+
let lua = self.lock();
893+
unsafe {
894+
(*lua.extra.get()).debug_step_callback = Some(XRc::new(callback));
895+
(*ffi::lua_callbacks(lua.main_state())).debugstep = Some(debug_step_proc);
896+
}
897+
}
898+
899+
/// Enables or disables single-stepping (wraps `lua_singlestep`).
900+
///
901+
/// While enabled, the callback registered with [`Lua::set_debug_step`] fires for every line.
902+
/// The flag is per-thread; threads created afterwards inherit it, so enable it before creating
903+
/// the thread you want to step.
904+
#[cfg(any(feature = "luau", doc))]
905+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
906+
pub fn set_single_step(&self, enabled: bool) {
907+
let lua = self.lock();
908+
unsafe { ffi::lua_singlestep(lua.main_state(), enabled as c_int) };
909+
}
910+
911+
/// Removes the breakpoint callback previously set by [`Lua::set_debug_break`].
912+
#[cfg(any(feature = "luau", doc))]
913+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
914+
pub fn remove_debug_break(&self) {
915+
let lua = self.lock();
916+
unsafe {
917+
(*lua.extra.get()).debug_break_callback = None;
918+
(*ffi::lua_callbacks(lua.main_state())).debugbreak = None;
919+
}
920+
}
921+
922+
/// Removes the single-step callback previously set by [`Lua::set_debug_step`].
923+
#[cfg(any(feature = "luau", doc))]
924+
#[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
925+
pub fn remove_debug_step(&self) {
926+
let lua = self.lock();
927+
unsafe {
928+
(*lua.extra.get()).debug_step_callback = None;
929+
(*ffi::lua_callbacks(lua.main_state())).debugstep = None;
930+
}
931+
}
932+
845933
/// Sets a callback invoked when thread lifecycle events occur.
846934
///
847935
/// `triggers` controls which events trigger the callback, see [`ThreadTriggers`] for more

src/state/extra.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ pub(crate) struct ExtraData {
8282
pub(super) warn_callback: Option<crate::types::WarnCallback>,
8383
#[cfg(feature = "luau")]
8484
pub(super) interrupt_callback: Option<crate::types::InterruptCallback>,
85+
#[cfg(feature = "luau")]
86+
pub(super) debug_break_callback: Option<crate::types::DebugCallback>,
87+
#[cfg(feature = "luau")]
88+
pub(super) debug_step_callback: Option<crate::types::DebugCallback>,
8589
pub(super) thread_triggers: ThreadTriggers,
8690
pub(super) thread_event_callback: Option<ThreadEventCallback>,
8791

@@ -185,6 +189,10 @@ impl ExtraData {
185189
warn_callback: None,
186190
#[cfg(feature = "luau")]
187191
interrupt_callback: None,
192+
#[cfg(feature = "luau")]
193+
debug_break_callback: None,
194+
#[cfg(feature = "luau")]
195+
debug_step_callback: None,
188196
thread_triggers: ThreadTriggers::default(),
189197
thread_event_callback: None,
190198
#[cfg(feature = "luau")]

src/state/util.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ use std::panic::{AssertUnwindSafe, catch_unwind};
33
use std::ptr;
44
use std::sync::Arc;
55

6+
#[cfg(feature = "luau")]
7+
use crate::debug::Debug;
68
use crate::error::{Error, Result};
79
use crate::state::{ExtraData, RawLua};
10+
#[cfg(feature = "luau")]
11+
use crate::types::{DebugCallback, VmState, XRc};
812
use crate::util::{self, WrappedFailure, get_internal_metatable};
913

1014
struct StateGuard<'a>(&'a RawLua, *mut ffi::lua_State);
@@ -22,6 +26,68 @@ impl Drop for StateGuard<'_> {
2226
}
2327
}
2428

29+
/// Runs a Luau `debugbreak`/`debugstep` callback (selected by `select`) for the paused frame and
30+
/// maps its [`VmState`] result the same way the `interrupt` callback does.
31+
///
32+
/// Unlike [`callback_error_ext`], the pre-allocated failure userdata is reserved at the *top* of
33+
/// the stack rather than inserted at the running frame's base, so the paused function's registers
34+
/// stay readable through [`Debug::get_local`]. The callback runs inline in the VM (no extra call
35+
/// frame), hence it cannot reuse the regular callback path.
36+
#[cfg(feature = "luau")]
37+
pub(crate) unsafe fn debug_callback(
38+
state: *mut ffi::lua_State,
39+
ar: *mut ffi::lua_Debug,
40+
select: impl Fn(*mut ExtraData) -> Option<DebugCallback>,
41+
) {
42+
let extra = ExtraData::get(state);
43+
44+
// Reserve memory for a wrapped failure *before* running the callback (an error must not be
45+
// shadowed by an allocation failure), keeping it at the top so registers are not shifted.
46+
ffi::lua_rawcheckstack(state, 2);
47+
let wrapped_failure = WrappedFailure::new_userdata(state);
48+
let failure_idx = ffi::lua_gettop(state);
49+
50+
let result = catch_unwind(AssertUnwindSafe(|| {
51+
let rawlua = (*extra).raw_lua();
52+
let _guard = StateGuard::new(rawlua, state);
53+
let callback = match select(extra) {
54+
// A strong count above 2 means the callback is already on the stack: avoid recursion.
55+
Some(callback) if XRc::strong_count(&callback) <= 2 => callback,
56+
_ => return Ok(VmState::Continue),
57+
};
58+
callback((*extra).lua(), &Debug::new(rawlua, 0, ar))
59+
}));
60+
61+
let raise = |failure: WrappedFailure| {
62+
ptr::write(wrapped_failure, failure);
63+
get_internal_metatable::<WrappedFailure>(state);
64+
ffi::lua_setmetatable(state, -2);
65+
ffi::lua_error(state)
66+
};
67+
68+
match result {
69+
Ok(Ok(state_result)) => {
70+
ffi::lua_settop(state, failure_idx - 1); // drop the unused failure
71+
if state_result == VmState::Yield && ffi::lua_isyieldable(state) != 0 {
72+
ffi::lua_yield(state, 0);
73+
}
74+
}
75+
Ok(Err(err)) => {
76+
let traceback = if ffi::lua_checkstack(state, ffi::LUA_TRACEBACK_STACK) != 0 {
77+
ffi::luaL_traceback(state, state, ptr::null(), 0);
78+
let traceback = util::to_string(state, -1);
79+
ffi::lua_pop(state, 1);
80+
traceback
81+
} else {
82+
"<not enough stack space for traceback>".to_string()
83+
};
84+
let cause = Arc::new(err);
85+
raise(WrappedFailure::Error(Error::CallbackError { traceback, cause }));
86+
}
87+
Err(panic) => raise(WrappedFailure::Panic(Some(panic))),
88+
}
89+
}
90+
2591
// An optimized version of `callback_error` that does not allocate `WrappedFailure` userdata
2692
// and instead reuses unused values from previous calls (or allocates new).
2793
pub(crate) unsafe fn callback_error_ext<F, R>(

src/types.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use std::cell::UnsafeCell;
22
use std::os::raw::{c_int, c_void};
33

4+
use crate::debug::Debug;
45
#[cfg(not(feature = "luau"))]
5-
use crate::debug::{Debug, HookTriggers};
6+
use crate::debug::HookTriggers;
67
use crate::error::Result;
78
use crate::state::{ExtraData, Lua, RawLua};
89

@@ -70,6 +71,7 @@ pub(crate) type AsyncCallbackUpvalue = Upvalue<AsyncCallback>;
7071
pub(crate) type AsyncPollUpvalue = Upvalue<Option<BoxFuture<'static, Result<c_int>>>>;
7172

7273
/// Type to set next Lua VM action after executing interrupt or hook function.
74+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7375
pub enum VmState {
7476
Continue,
7577
/// Yield the current thread.
@@ -96,6 +98,12 @@ pub(crate) type InterruptCallback = XRc<dyn Fn(&Lua) -> Result<VmState> + Send>;
9698
#[cfg(all(not(feature = "send"), feature = "luau"))]
9799
pub(crate) type InterruptCallback = XRc<dyn Fn(&Lua) -> Result<VmState>>;
98100

101+
#[cfg(all(feature = "send", feature = "luau"))]
102+
pub(crate) type DebugCallback = XRc<dyn Fn(&Lua, &Debug) -> Result<VmState> + Send>;
103+
104+
#[cfg(all(not(feature = "send"), feature = "luau"))]
105+
pub(crate) type DebugCallback = XRc<dyn Fn(&Lua, &Debug) -> Result<VmState>>;
106+
99107
#[cfg(feature = "send")]
100108
pub(crate) type ThreadEventCallback = XRc<dyn Fn(&Lua, crate::thread::ThreadEvent) -> Result<()> + Send>;
101109

0 commit comments

Comments
 (0)