diff --git a/src/debug.rs b/src/debug.rs
index b80be4bda..7e2c4d90b 100644
--- a/src/debug.rs
+++ b/src/debug.rs
@@ -15,6 +15,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,222 @@ 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(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn get_local(&self, index: usize) -> Option<(String, Value)> {
+ let index = c_int::try_from(index).ok().filter(|&index| index > 0)?;
+ 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))?;
+ Some((name.into_owned(), self.lua.pop_value()))
+ }
+ }
+
+ /// Assigns `value` to local variable `index` (1-based) in this activation record.
+ ///
+ /// Returns `Ok(Some(name))` with the local's name when the write succeeded, or `Ok(None)` when
+ /// `index` is out of range or the frame belongs to a **native-compiled** function (Luau's JIT
+ /// guard: `LUA_CALLINFO_NATIVE` prevents writes to native frames because register type tags
+ /// could be invalidated).
+ ///
+ /// The replacement preserves the VM value tag. Interpreted bytecode can
+ /// assume a register's type (for example, a numeric loop index).
+ /// Integer/number conversions are accepted when they preserve the value.
+ ///
+ /// Only meaningful inside a [`Lua::set_debug_break`] / [`Lua::set_debug_step`] callback. For
+ /// reliable local visibility, compile the chunk with
+ /// [`Compiler::set_optimization_level(0)`][opt] and [`Compiler::set_debug_level(2)`][dbg].
+ ///
+ /// [opt]: crate::Compiler::set_optimization_level
+ /// [dbg]: crate::Compiler::set_debug_level
+ /// [`Lua::set_debug_break`]: crate::Lua::set_debug_break
+ /// [`Lua::set_debug_step`]: crate::Lua::set_debug_step
+ #[cfg(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn set_local(&self, index: usize, value: Value) -> Result> {
+ let Ok(index) = c_int::try_from(index) else {
+ return Ok(None);
+ };
+ if index == 0 {
+ return Ok(None);
+ }
+ unsafe {
+ // `lua_setlocal` pops the value on success and leaves it on the stack on failure
+ // (out-of-range or native frame). The `StackGuard` restores the top in every case.
+ let _sg = StackGuard::new(self.state);
+ assert_stack(self.state, 1);
+ if ffi::lua_getlocal(self.state, self.level, index).is_null() {
+ return Ok(None);
+ }
+ self.push_debug_replacement(value)?;
+ let name = ffi::lua_setlocal(self.state, self.level, index);
+ Ok(ptr_to_lossy_str(name).map(|s| s.into_owned()))
+ }
+ }
+
+ /// Collects every readable local in this record as `(name, value)` pairs, in index order.
+ ///
+ /// Convenience wrapper over [`Debug::get_local`].
+ #[cfg(feature = "luau")]
+ #[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
+ }
+
+ /// Returns the name and current value of the `index`-th upvalue (1-based) of the function
+ /// running at this stack level. Returns `None` when `index` is out of range
+ /// or the frame is a native host function.
+ ///
+ /// The function is pushed then popped internally; the stack is left unchanged.
+ #[cfg(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn get_upvalue(&self, index: usize) -> Option<(String, Value)> {
+ let index = c_int::try_from(index).ok().filter(|&index| index > 0)?;
+ unsafe {
+ let _sg = StackGuard::new(self.state);
+ assert_stack(self.state, 2); // function + upvalue value
+
+ // Push the function running at this level.
+ mlua_assert!(
+ ffi::lua_getinfo(self.state, self.level, cstr!("f"), self.ar) != 0,
+ "lua_getinfo failed with `f`"
+ );
+
+ let func_idx = ffi::lua_gettop(self.state);
+ // C closures can hold private state owned by mlua or another
+ // host binding. Never expose or overwrite these slots.
+ if ffi::lua_iscfunction(self.state, func_idx) != 0 {
+ return None;
+ }
+
+ // lua_getupvalue pushes the value and returns its name, or returns null and
+ // pushes nothing when `index` exceeds the upvalue count.
+ let name = ptr_to_lossy_str(ffi::lua_getupvalue(self.state, func_idx, index))?;
+ let value = self.lua.pop_value();
+ Some((name.into_owned(), value))
+ // _sg restores the stack top to before the function push, popping it.
+ }
+ }
+
+ /// Collects every upvalue of the function at this stack level as `(name, value)` pairs.
+ ///
+ /// Convenience wrapper over [`Debug::get_upvalue`].
+ #[cfg(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn upvalues(&self) -> Vec<(String, Value)> {
+ let mut upvalues = Vec::new();
+ let mut index = 1;
+ while let Some(upval) = self.get_upvalue(index) {
+ upvalues.push(upval);
+ index += 1;
+ }
+ upvalues
+ }
+
+ /// Sets the `index`-th upvalue (1-based) of the function running at this stack level.
+ /// Returns the upvalue's name on success, `None` when `index` is out of range
+ /// or the frame is a native host function.
+ ///
+ /// Because upvalues are shared with every closure capturing them, the write is visible
+ /// to the running function as soon as it resumes.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the replacement changes the VM value tag, cannot be
+ /// pushed, or Luau code generation is enabled in this build. A captured
+ /// slot may be shared with a different, native-compiled closure.
+ #[cfg(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn set_upvalue(&self, index: usize, value: Value) -> Result > {
+ let Ok(index) = c_int::try_from(index) else {
+ return Ok(None);
+ };
+ if index == 0 {
+ return Ok(None);
+ }
+ if cfg!(feature = "luau-jit") {
+ return Err(crate::Error::runtime(
+ "cannot write Luau upvalues with native code generation enabled",
+ ));
+ }
+ unsafe {
+ let _sg = StackGuard::new(self.state);
+ assert_stack(self.state, 3); // function + value + slack
+
+ // Push the function running at this level.
+ mlua_assert!(
+ ffi::lua_getinfo(self.state, self.level, cstr!("f"), self.ar) != 0,
+ "lua_getinfo failed with `f`"
+ );
+
+ let func_idx = ffi::lua_gettop(self.state);
+ // C closures can hold private state owned by mlua or another
+ // host binding. Never expose or overwrite these slots.
+ if ffi::lua_iscfunction(self.state, func_idx) != 0 {
+ return Ok(None);
+ }
+ if ffi::lua_getupvalue(self.state, func_idx, index).is_null() {
+ return Ok(None);
+ }
+ self.push_debug_replacement(value)?;
+ // `lua_setupvalue` pops the value on success and leaves it on failure
+ // (out-of-range); the `StackGuard` restores the top in every case.
+ let name = ffi::lua_setupvalue(self.state, func_idx, index);
+ Ok(ptr_to_lossy_str(name).map(|s| s.into_owned()))
+ }
+ }
+
+ // Replace the slot value on top of the stack without changing the raw tag.
+ // mlua may present a whole Luau number as Value::Integer, so check the VM
+ // tag instead of comparing Rust Value variants.
+ #[cfg(feature = "luau")]
+ unsafe fn push_debug_replacement(&self, value: Value) -> Result<()> {
+ let tag = ffi::lua_type(self.state, -1);
+ ffi::lua_pop(self.state, 1);
+ let value = match (tag, value) {
+ (ffi::LUA_TNUMBER, Value::Integer(i)) if (i as f64) as i128 == i as i128 => {
+ Value::Number(i as f64)
+ }
+ (ffi::LUA_TINTEGER, Value::Number(n))
+ if n < i64::MAX as f64 && n >= i64::MIN as f64 && (n as i64) as f64 == n =>
+ {
+ Value::Integer(n as i64)
+ }
+ (_, value) => value,
+ };
+ self.lua.push_value(&value)?;
+ if ffi::lua_type(self.state, -1) != tag {
+ return Err(crate::Error::runtime(
+ "debug variable replacement would change the Luau value type",
+ ));
+ }
+ Ok(())
+ }
+
+ /// Returns the number of frames in the active Luau thread.
+ #[cfg(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn stack_depth(&self) -> usize {
+ unsafe { ffi::lua_stackdepth(self.state) as usize }
+ }
}
/// Represents a specific event that triggered the hook.
diff --git a/src/function.rs b/src/function.rs
index c13e8ad1f..6942613b8 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -597,6 +597,32 @@ 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(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn set_breakpoint(&self, line: u32, enabled: bool) -> Option {
+ let line = c_int::try_from(line).ok()?;
+ 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);
+ if ffi::lua_iscfunction(state, -1) != 0 {
+ return None;
+ }
+ let actual = ffi::lua_breakpoint(state, -1, line, 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 6b2e7ea9d..746139a91 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 {
@@ -889,6 +891,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(feature = "luau")]
+ #[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(feature = "luau")]
+ #[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(feature = "luau")]
+ #[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(feature = "luau")]
+ #[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(feature = "luau")]
+ #[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 038855d3a..cfa31d004 100644
--- a/src/state/extra.rs
+++ b/src/state/extra.rs
@@ -90,6 +90,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,
pub(super) thread_event_state: *mut ffi::lua_State,
@@ -206,6 +210,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,
thread_event_state: ptr::null_mut(),
diff --git a/src/state/util.rs b/src/state/util.rs
index 1d7af40e6..8aa25dfec 100644
--- a/src/state/util.rs
+++ b/src/state/util.rs
@@ -3,8 +3,12 @@ use std::panic::{AssertUnwindSafe, catch_unwind};
use std::ptr::{self, NonNull};
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/thread.rs b/src/thread.rs
index 9a43b558f..fbbc67ca8 100644
--- a/src/thread.rs
+++ b/src/thread.rs
@@ -531,6 +531,18 @@ impl Thread {
self.status() == ThreadStatus::Error
}
+ /// Enables or disables Luau's single-step flag for this coroutine.
+ ///
+ /// The debug step callback is registered with [`Lua::set_debug_step`] on
+ /// the owning VM. The flag can be changed while the coroutine is suspended
+ /// so ordinary resumes can run without invoking the step callback.
+ #[cfg(feature = "luau")]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn set_single_step(&self, enabled: bool) {
+ let _lua = self.0.lua.lock();
+ unsafe { ffi::lua_singlestep(self.state(), enabled as c_int) };
+ }
+
/// Sets a hook function that will periodically be called as Lua code executes.
///
/// This function is similar to [`Lua::set_hook`] except that it sets the hook for the thread.
diff --git a/src/types.rs b/src/types.rs
index 6c7704dbd..32d2bd2c2 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};
@@ -67,6 +68,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)]
#[non_exhaustive]
pub enum VmState {
Continue,
@@ -94,6 +96,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 0ae10cc8b..fa452e0b5 100644
--- a/tests/luau.rs
+++ b/tests/luau.rs
@@ -351,6 +351,283 @@ 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_step_can_be_toggled_on_a_suspended_thread() -> Result<()> {
+ let lua = debug_lua();
+ let hits = Arc::new(AtomicU64::new(0));
+ let step_hits = hits.clone();
+ lua.set_debug_step(move |_, _| {
+ step_hits.fetch_add(1, Ordering::Relaxed);
+ Ok(VmState::Yield)
+ });
+
+ let function = lua.load(DEBUG_CHUNK).into_function()?;
+ let thread = lua.create_thread(function.clone())?;
+ thread.set_single_step(true);
+ thread.resume::<()>(())?;
+ assert!(thread.is_resumable());
+ assert_eq!(hits.load(Ordering::Relaxed), 1);
+
+ assert_eq!(lua.create_thread(function)?.resume::(())?, 3);
+ thread.set_single_step(false);
+ assert_eq!(thread.resume::(())?, 3);
+ assert_eq!(hits.load(Ordering::Relaxed), 1);
+ Ok(())
+}
+
+#[test]
+fn test_debug_breakpoint_rejects_native_functions_and_invalid_lines() -> Result<()> {
+ let lua = debug_lua();
+ let native = lua.create_function(|_, ()| Ok(42))?;
+ assert_eq!(native.set_breakpoint(1, true), None);
+ assert_eq!(native.call::(())?, 42);
+
+ let function = lua.load(DEBUG_CHUNK).into_function()?;
+ assert_eq!(function.set_breakpoint(u32::MAX, true), None);
+ Ok(())
+}
+
+#[test]
+fn test_debug_upvalues_are_unavailable_for_native_functions() -> Result<()> {
+ let lua = debug_lua();
+ let native = lua.create_function(|lua, ()| {
+ lua.inspect_stack(0, |frame| {
+ assert!(frame.get_upvalue(1).is_none());
+ let write = frame.set_upvalue(1, Value::Nil);
+ if cfg!(feature = "luau-jit") {
+ assert!(write.is_err());
+ } else {
+ assert_eq!(write?, None);
+ }
+ Ok(())
+ })
+ .expect("native frame")
+ })?;
+ native.call(())
+}
+
+#[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_eq!(debug.set_local(1, Value::Integer(10))?, Some("a".to_string()));
+ }
+ 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_set_local_out_of_range() -> Result<()> {
+ let lua = debug_lua();
+
+ lua.set_debug_break(|_, debug| {
+ // Index 99 is well past the end of any local list.
+ assert_eq!(debug.set_local(99, Value::Integer(0))?, None);
+ Ok(VmState::Continue)
+ });
+
+ let f = lua.load(DEBUG_CHUNK).into_function()?;
+ f.set_breakpoint(4, true).expect("breakpoint was not placed");
+ let _: i32 = f.call(())?;
+
+ Ok(())
+}
+
+#[test]
+fn test_set_local_rejects_type_changes_before_the_vm_uses_the_register() -> Result<()> {
+ let lua = debug_lua();
+ lua.set_debug_break(|lua, debug| {
+ let error = debug
+ .set_local(1, Value::String(lua.create_string("not_a_number")?))
+ .expect_err("the register must keep its numeric tag");
+ assert!(error.to_string().contains("change the Luau value type"));
+ Ok(VmState::Continue)
+ });
+
+ let function = lua.load(DEBUG_CHUNK).into_function()?;
+ function
+ .set_breakpoint(4, true)
+ .expect("breakpoint was not placed");
+ assert_eq!(function.call::(())?, 3);
+ Ok(())
+}
+
#[test]
fn test_interrupt_callback_drop() {
struct ReenterOnDrop(Lua);