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