diff --git a/src/debug.rs b/src/debug.rs
index 89d8c501..2c43bb45 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,159 @@ 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 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).
+ ///
+ /// For **interpreted** frames the write always succeeds at the C level. If the new value's type
+ /// differs from what the Luau bytecode expects, a `RuntimeError` will be raised the next time
+ /// the VM touches that register — not here, and not as Rust UB. This matches the behaviour of
+ /// `debug.setlocal` in standard Lua and is intentional: it lets debuggers write any value and
+ /// observe the resulting Lua error without compromising memory safety.
+ ///
+ /// 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(any(feature = "luau", doc))]
+ #[cfg_attr(docsrs, doc(cfg(feature = "luau")))]
+ pub fn set_local(&self, index: usize, value: Value) -> Result> {
+ 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);
+
+ self.lua.push_value(&value)?;
+ let name = ffi::lua_setlocal(self.state, self.level, index as c_int);
+ 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(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
+ }
+
+ /// 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.
+ ///
+ /// The function is pushed then popped internally; the stack is left unchanged.
+ pub fn get_upvalue(&self, index: usize) -> Option<(String, Value)> {
+ unsafe {
+ let _sg = StackGuard::new(self.state);
+ assert_stack(self.state, 2); // function + upvalue value
+
+ // Push the function running at this level.
+ #[cfg(not(feature = "luau"))]
+ mlua_assert!(
+ ffi::lua_getinfo(self.state, cstr!("f"), self.ar) != 0,
+ "lua_getinfo failed with `f`"
+ );
+ #[cfg(feature = "luau")]
+ 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);
+
+ // 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 as c_int))?;
+ 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`].
+ 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.
+ ///
+ /// 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 `value` cannot be pushed onto the Lua stack.
+ pub fn set_upvalue(&self, index: usize, value: Value) -> Result > {
+ unsafe {
+ let _sg = StackGuard::new(self.state);
+ assert_stack(self.state, 3); // function + value + slack
+
+ // Push the function running at this level.
+ #[cfg(not(feature = "luau"))]
+ mlua_assert!(
+ ffi::lua_getinfo(self.state, cstr!("f"), self.ar) != 0,
+ "lua_getinfo failed with `f`"
+ );
+ #[cfg(feature = "luau")]
+ 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);
+ self.lua.push_value(&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 as c_int);
+ Ok(ptr_to_lossy_str(name).map(|s| s.into_owned()))
+ }
+ }
}
/// 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..1270053b 100644
--- a/tests/luau.rs
+++ b/tests/luau.rs
@@ -351,6 +351,233 @@ 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_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_type_mismatch_causes_lua_error() -> Result<()> {
+ // Replace the integer local `a` with a string value. The write itself
+ // succeeds (returns the local's name), but the VM raises a RuntimeError
+ // when it tries to add a string to `b` on the next line. No Rust UB.
+ let lua = debug_lua();
+
+ lua.set_debug_break(|lua, debug| {
+ let name = debug.set_local(1, Value::String(lua.create_string("not_a_number")?))?;
+ assert_eq!(name, 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");
+
+ match f.call::(()) {
+ Err(_) => {} // RuntimeError from `a + b` where `a` is now a string.
+ Ok(v) => panic!("expected a Lua error, got {v}"),
+ }
+
+ Ok(())
+}
+
#[test]
fn test_fflags() {
// We cannot really on any particular feature flag to be present