From d85971a9a55a5c8de508a5b4ada392832c150a23 Mon Sep 17 00:00:00 2001 From: w4nderlust Date: Thu, 4 Jun 2026 03:20:48 -0700 Subject: [PATCH] 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. --- src/debug.rs | 55 ++++++++++++++ src/function.rs | 22 ++++++ src/state.rs | 88 +++++++++++++++++++++ src/state/extra.rs | 8 ++ src/state/util.rs | 66 ++++++++++++++++ src/types.rs | 10 ++- tests/luau.rs | 186 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 434 insertions(+), 1 deletion(-) diff --git a/src/debug.rs b/src/debug.rs index 89d8c501..0697d593 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -12,6 +12,8 @@ use ffi::{lua_Debug, lua_State}; use crate::function::Function; use crate::state::RawLua; use crate::util::{StackGuard, assert_stack, linenumber_to_usize, ptr_to_lossy_str, ptr_to_str}; +#[cfg(feature = "luau")] +use crate::{error::Result, value::Value}; /// Contains information about currently executing Lua code. /// @@ -205,6 +207,59 @@ impl<'a> Debug<'a> { stack } } + + /// Reads local variable `index` (1-based) in this activation record, returning its name and + /// current value, or `None` once `index` is past the last visible local (wraps `lua_getlocal`). + /// + /// Luau keeps locals reachable here even though its sandbox removes `debug.getlocal`, so this is + /// the way to inspect locals from a [`Lua::set_debug_break`]/[`Lua::set_debug_step`] callback. + /// + /// [`Lua::set_debug_break`]: crate::Lua::set_debug_break + /// [`Lua::set_debug_step`]: crate::Lua::set_debug_step + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn get_local(&self, index: usize) -> Option<(String, Value)> { + unsafe { + let _sg = StackGuard::new(self.state); + assert_stack(self.state, 1); + + // `lua_getlocal` pushes the value only when a local exists; otherwise it returns null. + let name = ptr_to_lossy_str(ffi::lua_getlocal(self.state, self.level, index as c_int))?; + Some((name.into_owned(), self.lua.pop_value())) + } + } + + /// Assigns `value` to local variable `index` (1-based) in this record, returning `true` if a + /// local with that index exists (wraps `lua_setlocal`). + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn set_local(&self, index: usize, value: Value) -> Result { + unsafe { + // `lua_setlocal` may leave the value on the stack when `index` is out of range; the + // `StackGuard` restores the top in every case. + let _sg = StackGuard::new(self.state); + assert_stack(self.state, 1); + + self.lua.push_value(&value)?; + let name = ffi::lua_setlocal(self.state, self.level, index as c_int); + Ok(!name.is_null()) + } + } + + /// Collects every readable local in this record as `(name, value)` pairs, in index order. + /// + /// Convenience wrapper over [`Debug::get_local`]. + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn locals(&self) -> Vec<(String, Value)> { + let mut locals = Vec::new(); + let mut index = 1; + while let Some(local) = self.get_local(index) { + locals.push(local); + index += 1; + } + locals + } } /// Represents a specific event that triggered the hook. diff --git a/src/function.rs b/src/function.rs index 234bbe23..61a991bc 100644 --- a/src/function.rs +++ b/src/function.rs @@ -582,6 +582,28 @@ impl Function { } } + /// Sets or clears a breakpoint on `line` of this Luau function (wraps `lua_breakpoint`). + /// + /// Luau snaps the breakpoint to the next executable line, so the returned value is the line it + /// was actually placed on (which may differ from `line`), or `None` if no executable line was + /// found. Pair this with [`Lua::set_debug_break`] to observe the hit. + /// + /// [`Lua::set_debug_break`]: crate::Lua::set_debug_break + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn set_breakpoint(&self, line: u32, enabled: bool) -> Option { + let lua = self.0.lua.lock(); + let state = lua.state(); + unsafe { + let _sg = StackGuard::new(state); + assert_stack(state, 1); + + lua.push_ref(&self.0); + let actual = ffi::lua_breakpoint(state, -1, line as c_int, enabled as c_int); + linenumber_to_usize(actual).map(|line| line as u32) + } + } + /// Converts this function to a generic C pointer. /// /// There is no way to convert the pointer back to its original value. diff --git a/src/state.rs b/src/state.rs index 7c3fcfd8..df5b4687 100644 --- a/src/state.rs +++ b/src/state.rs @@ -52,6 +52,8 @@ pub(crate) use extra::ExtraData; #[doc(hidden)] pub use raw::RawLua; pub(crate) use util::callback_error_ext; +#[cfg(feature = "luau")] +pub(crate) use util::debug_callback; /// Top level Lua struct which represents an instance of Lua VM. pub struct Lua { @@ -842,6 +844,92 @@ impl Lua { } } + /// Sets the callback Luau invokes when a breakpoint (set via [`Function::set_breakpoint`]) is + /// hit. + /// + /// The callback receives a [`Debug`] for the paused frame, so it can read the current line and + /// inspect locals. Returning [`VmState::Yield`] suspends the running coroutine at the + /// breakpoint (the yield happens only at yieldable points, exactly as [`Lua::set_interrupt`]), + /// which is what makes a non-blocking, single-threaded debugger possible. + /// + /// Note: Luau re-evaluates the breakpoint when the coroutine is resumed, so the callback fires + /// again on the same line. Return [`VmState::Continue`] on resume to step past it (a debugger + /// typically yields on the first hit and continues once the user resumes). + /// + /// [`Function::set_breakpoint`]: crate::Function::set_breakpoint + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn set_debug_break(&self, callback: F) + where + F: Fn(&Lua, &Debug) -> Result + MaybeSend + 'static, + { + unsafe extern "C-unwind" fn debug_break_proc(state: *mut ffi::lua_State, ar: *mut ffi::lua_Debug) { + debug_callback(state, ar, |extra| (*extra).debug_break_callback.clone()); + } + + let lua = self.lock(); + unsafe { + (*lua.extra.get()).debug_break_callback = Some(XRc::new(callback)); + (*ffi::lua_callbacks(lua.main_state())).debugbreak = Some(debug_break_proc); + } + } + + /// Sets the per-line single-step callback, invoked for each line while single-stepping is + /// enabled via [`Lua::set_single_step`]. + /// + /// Like [`Lua::set_debug_break`], the callback may return [`VmState::Yield`] to suspend the + /// running coroutine. + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn set_debug_step(&self, callback: F) + where + F: Fn(&Lua, &Debug) -> Result + MaybeSend + 'static, + { + unsafe extern "C-unwind" fn debug_step_proc(state: *mut ffi::lua_State, ar: *mut ffi::lua_Debug) { + debug_callback(state, ar, |extra| (*extra).debug_step_callback.clone()); + } + + let lua = self.lock(); + unsafe { + (*lua.extra.get()).debug_step_callback = Some(XRc::new(callback)); + (*ffi::lua_callbacks(lua.main_state())).debugstep = Some(debug_step_proc); + } + } + + /// Enables or disables single-stepping (wraps `lua_singlestep`). + /// + /// While enabled, the callback registered with [`Lua::set_debug_step`] fires for every line. + /// The flag is per-thread; threads created afterwards inherit it, so enable it before creating + /// the thread you want to step. + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn set_single_step(&self, enabled: bool) { + let lua = self.lock(); + unsafe { ffi::lua_singlestep(lua.main_state(), enabled as c_int) }; + } + + /// Removes the breakpoint callback previously set by [`Lua::set_debug_break`]. + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn remove_debug_break(&self) { + let lua = self.lock(); + unsafe { + (*lua.extra.get()).debug_break_callback = None; + (*ffi::lua_callbacks(lua.main_state())).debugbreak = None; + } + } + + /// Removes the single-step callback previously set by [`Lua::set_debug_step`]. + #[cfg(any(feature = "luau", doc))] + #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] + pub fn remove_debug_step(&self) { + let lua = self.lock(); + unsafe { + (*lua.extra.get()).debug_step_callback = None; + (*ffi::lua_callbacks(lua.main_state())).debugstep = None; + } + } + /// Sets a callback invoked when thread lifecycle events occur. /// /// `triggers` controls which events trigger the callback, see [`ThreadTriggers`] for more diff --git a/src/state/extra.rs b/src/state/extra.rs index 9889e63c..d96fd13e 100644 --- a/src/state/extra.rs +++ b/src/state/extra.rs @@ -82,6 +82,10 @@ pub(crate) struct ExtraData { pub(super) warn_callback: Option, #[cfg(feature = "luau")] pub(super) interrupt_callback: Option, + #[cfg(feature = "luau")] + pub(super) debug_break_callback: Option, + #[cfg(feature = "luau")] + pub(super) debug_step_callback: Option, pub(super) thread_triggers: ThreadTriggers, pub(super) thread_event_callback: Option, @@ -185,6 +189,10 @@ impl ExtraData { warn_callback: None, #[cfg(feature = "luau")] interrupt_callback: None, + #[cfg(feature = "luau")] + debug_break_callback: None, + #[cfg(feature = "luau")] + debug_step_callback: None, thread_triggers: ThreadTriggers::default(), thread_event_callback: None, #[cfg(feature = "luau")] diff --git a/src/state/util.rs b/src/state/util.rs index fdbc5cf4..54e79912 100644 --- a/src/state/util.rs +++ b/src/state/util.rs @@ -3,8 +3,12 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::ptr; use std::sync::Arc; +#[cfg(feature = "luau")] +use crate::debug::Debug; use crate::error::{Error, Result}; use crate::state::{ExtraData, RawLua}; +#[cfg(feature = "luau")] +use crate::types::{DebugCallback, VmState, XRc}; use crate::util::{self, WrappedFailure, get_internal_metatable}; struct StateGuard<'a>(&'a RawLua, *mut ffi::lua_State); @@ -22,6 +26,68 @@ impl Drop for StateGuard<'_> { } } +/// Runs a Luau `debugbreak`/`debugstep` callback (selected by `select`) for the paused frame and +/// maps its [`VmState`] result the same way the `interrupt` callback does. +/// +/// Unlike [`callback_error_ext`], the pre-allocated failure userdata is reserved at the *top* of +/// the stack rather than inserted at the running frame's base, so the paused function's registers +/// stay readable through [`Debug::get_local`]. The callback runs inline in the VM (no extra call +/// frame), hence it cannot reuse the regular callback path. +#[cfg(feature = "luau")] +pub(crate) unsafe fn debug_callback( + state: *mut ffi::lua_State, + ar: *mut ffi::lua_Debug, + select: impl Fn(*mut ExtraData) -> Option, +) { + let extra = ExtraData::get(state); + + // Reserve memory for a wrapped failure *before* running the callback (an error must not be + // shadowed by an allocation failure), keeping it at the top so registers are not shifted. + ffi::lua_rawcheckstack(state, 2); + let wrapped_failure = WrappedFailure::new_userdata(state); + let failure_idx = ffi::lua_gettop(state); + + let result = catch_unwind(AssertUnwindSafe(|| { + let rawlua = (*extra).raw_lua(); + let _guard = StateGuard::new(rawlua, state); + let callback = match select(extra) { + // A strong count above 2 means the callback is already on the stack: avoid recursion. + Some(callback) if XRc::strong_count(&callback) <= 2 => callback, + _ => return Ok(VmState::Continue), + }; + callback((*extra).lua(), &Debug::new(rawlua, 0, ar)) + })); + + let raise = |failure: WrappedFailure| { + ptr::write(wrapped_failure, failure); + get_internal_metatable::(state); + ffi::lua_setmetatable(state, -2); + ffi::lua_error(state) + }; + + match result { + Ok(Ok(state_result)) => { + ffi::lua_settop(state, failure_idx - 1); // drop the unused failure + if state_result == VmState::Yield && ffi::lua_isyieldable(state) != 0 { + ffi::lua_yield(state, 0); + } + } + Ok(Err(err)) => { + let traceback = if ffi::lua_checkstack(state, ffi::LUA_TRACEBACK_STACK) != 0 { + ffi::luaL_traceback(state, state, ptr::null(), 0); + let traceback = util::to_string(state, -1); + ffi::lua_pop(state, 1); + traceback + } else { + "".to_string() + }; + let cause = Arc::new(err); + raise(WrappedFailure::Error(Error::CallbackError { traceback, cause })); + } + Err(panic) => raise(WrappedFailure::Panic(Some(panic))), + } +} + // An optimized version of `callback_error` that does not allocate `WrappedFailure` userdata // and instead reuses unused values from previous calls (or allocates new). pub(crate) unsafe fn callback_error_ext( diff --git a/src/types.rs b/src/types.rs index 06ecf277..6e3f6f15 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,8 +1,9 @@ use std::cell::UnsafeCell; use std::os::raw::{c_int, c_void}; +use crate::debug::Debug; #[cfg(not(feature = "luau"))] -use crate::debug::{Debug, HookTriggers}; +use crate::debug::HookTriggers; use crate::error::Result; use crate::state::{ExtraData, Lua, RawLua}; @@ -70,6 +71,7 @@ pub(crate) type AsyncCallbackUpvalue = Upvalue; pub(crate) type AsyncPollUpvalue = Upvalue>>>; /// Type to set next Lua VM action after executing interrupt or hook function. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VmState { Continue, /// Yield the current thread. @@ -96,6 +98,12 @@ pub(crate) type InterruptCallback = XRc Result + Send>; #[cfg(all(not(feature = "send"), feature = "luau"))] pub(crate) type InterruptCallback = XRc Result>; +#[cfg(all(feature = "send", feature = "luau"))] +pub(crate) type DebugCallback = XRc Result + Send>; + +#[cfg(all(not(feature = "send"), feature = "luau"))] +pub(crate) type DebugCallback = XRc Result>; + #[cfg(feature = "send")] pub(crate) type ThreadEventCallback = XRc Result<()> + Send>; diff --git a/tests/luau.rs b/tests/luau.rs index 770640b9..4adbbb7b 100644 --- a/tests/luau.rs +++ b/tests/luau.rs @@ -351,6 +351,192 @@ fn test_interrupts() -> Result<()> { Ok(()) } +// A debugger compiles without optimizations so lines and locals survive intact. +#[cfg(feature = "luau")] +fn debug_lua() -> Lua { + let lua = Lua::new(); + lua.set_compiler(Compiler::new().set_optimization_level(0).set_debug_level(2)); + lua +} + +// Lines are 1-based; line 1 is the empty line after `r#"`. +const DEBUG_CHUNK: &str = r#" + local a = 1 + local b = 2 + local c = a + b + return c +"#; + +#[test] +fn test_debug_breakpoint() -> Result<()> { + let lua = debug_lua(); + + let break_line = Arc::new(AtomicU64::new(0)); + let break_line2 = break_line.clone(); + let hits = Arc::new(AtomicU64::new(0)); + let hits2 = hits.clone(); + lua.set_debug_break(move |_, debug| { + // Yield on the first hit; on resume the same breakpoint re-fires, so continue past it. + if hits2.fetch_add(1, Ordering::Relaxed) == 0 { + break_line2.store(debug.current_line().unwrap_or(0) as u64, Ordering::Relaxed); + return Ok(VmState::Yield); + } + Ok(VmState::Continue) + }); + + let f = lua.load(DEBUG_CHUNK).into_function()?; + let actual = f.set_breakpoint(4, true).expect("breakpoint was not placed"); + assert_eq!(actual, 4); // `local c = a + b` + + let co = lua.create_thread(f.clone())?; + co.resume::<()>(())?; + assert!(co.is_resumable()); + assert_eq!(break_line.load(Ordering::Relaxed), 4); + + let result: i32 = co.resume(())?; + assert_eq!(result, 3); + assert!(co.is_finished()); + + // Clearing the breakpoint stops pausing. + hits.store(0, Ordering::Relaxed); + f.set_breakpoint(4, false); + let result: i32 = lua.create_thread(f)?.resume(())?; + assert_eq!(result, 3); + assert_eq!(hits.load(Ordering::Relaxed), 0); + + Ok(()) +} + +#[test] +fn test_debug_breakpoint_multiline() -> Result<()> { + let lua = debug_lua(); + + let break_line = Arc::new(AtomicU64::new(0)); + let break_line2 = break_line.clone(); + lua.set_debug_break(move |_, debug| { + break_line2.store(debug.current_line().unwrap_or(0) as u64, Ordering::Relaxed); + Ok(VmState::Continue) + }); + + // A call whose arguments span several lines: a native breakpoint binds to the executable line + // Luau snaps to, which instrumentation can't reproduce. + let f = lua + .load( + r#" + local function add(x, y) return x + y end + local r = add( + 1, + 2 + ) + return r + "#, + ) + .into_function()?; + let actual = f.set_breakpoint(3, true).expect("breakpoint was not placed"); + assert!(actual >= 3); + + let result: i32 = f.call(())?; + assert_eq!(result, 3); + assert_eq!(break_line.load(Ordering::Relaxed), actual as u64); + + Ok(()) +} + +#[test] +fn test_debug_single_step() -> Result<()> { + let lua = debug_lua(); + + // Pause once per source line: Luau re-fires the step on resume, so continue while still on the + // line we paused at and yield only when execution reaches a new line. + let lines = Arc::new(std::sync::Mutex::new(Vec::new())); + let lines2 = lines.clone(); + lua.set_debug_step(move |_, debug| { + let line = debug.current_line().unwrap_or(0); + let mut lines = lines2.lock().unwrap(); + if lines.last() != Some(&line) { + lines.push(line); + return Ok(VmState::Yield); + } + Ok(VmState::Continue) + }); + + let f = lua.load(DEBUG_CHUNK).into_function()?; + + lua.set_single_step(true); + let co = lua.create_thread(f.clone())?; + let mut steps = 0; + while co.is_resumable() { + co.resume::<()>(())?; + steps += 1; + assert!(steps < 100, "single-step did not converge"); + } + assert_eq!(*lines.lock().unwrap(), vec![2, 3, 4, 5]); + + // Disabling single-step runs straight through without firing the callback. + lua.set_single_step(false); + let before = lines.lock().unwrap().len(); + let result: i32 = lua.create_thread(f)?.resume(())?; + assert_eq!(result, 3); + assert_eq!(lines.lock().unwrap().len(), before); + + Ok(()) +} + +#[test] +fn test_debug_locals() -> Result<()> { + let lua = debug_lua(); + + let captured = Arc::new(std::sync::Mutex::new(Vec::<(String, i64)>::new())); + let captured2 = captured.clone(); + let done = Arc::new(AtomicU64::new(0)); + let done2 = done.clone(); + lua.set_debug_break(move |_, debug| { + if done2.fetch_add(1, Ordering::Relaxed) == 0 { + for (name, value) in debug.locals() { + captured2 + .lock() + .unwrap() + .push((name, value.as_i64().unwrap_or(0))); + } + // Reassign `a` (local 1) so `c = a + b` evaluates to 12. + assert!(debug.set_local(1, Value::Integer(10))?); + } + Ok(VmState::Continue) + }); + + let f = lua.load(DEBUG_CHUNK).into_function()?; + f.set_breakpoint(4, true).expect("breakpoint was not placed"); + + let result: i32 = f.call(())?; + assert_eq!( + captured.lock().unwrap().as_slice(), + &[("a".into(), 1), ("b".into(), 2)] + ); + assert_eq!(result, 12); + + Ok(()) +} + +#[test] +fn test_debug_break_error() -> Result<()> { + let lua = debug_lua(); + + lua.set_debug_break(|_, _| Err(Error::runtime("error from breakpoint"))); + + let f = lua.load(DEBUG_CHUNK).into_function()?; + f.set_breakpoint(4, true).expect("breakpoint was not placed"); + + match f.call::<()>(()) { + Err(Error::CallbackError { cause, .. }) => match &*cause { + Error::RuntimeError(msg) => assert_eq!(msg, "error from breakpoint"), + err => panic!("expected `RuntimeError`, got {err:?}"), + }, + res => panic!("expected `CallbackError`, got {res:?}"), + } + + Ok(()) +} + #[test] fn test_fflags() { // We cannot really on any particular feature flag to be present