Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<bool> {
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.
Expand Down
22 changes: 22 additions & 0 deletions src/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32> {
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.
Expand Down
88 changes: 88 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<F>(&self, callback: F)
where
F: Fn(&Lua, &Debug) -> Result<VmState> + 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<F>(&self, callback: F)
where
F: Fn(&Lua, &Debug) -> Result<VmState> + 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
Expand Down
8 changes: 8 additions & 0 deletions src/state/extra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ pub(crate) struct ExtraData {
pub(super) warn_callback: Option<crate::types::WarnCallback>,
#[cfg(feature = "luau")]
pub(super) interrupt_callback: Option<crate::types::InterruptCallback>,
#[cfg(feature = "luau")]
pub(super) debug_break_callback: Option<crate::types::DebugCallback>,
#[cfg(feature = "luau")]
pub(super) debug_step_callback: Option<crate::types::DebugCallback>,
pub(super) thread_triggers: ThreadTriggers,
pub(super) thread_event_callback: Option<ThreadEventCallback>,

Expand Down Expand Up @@ -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")]
Expand Down
66 changes: 66 additions & 0 deletions src/state/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<DebugCallback>,
) {
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::<WrappedFailure>(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 {
"<not enough stack space for traceback>".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<F, R>(
Expand Down
10 changes: 9 additions & 1 deletion src/types.rs
Original file line number Diff line number Diff line change
@@ -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};

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

/// 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.
Expand All @@ -96,6 +98,12 @@ pub(crate) type InterruptCallback = XRc<dyn Fn(&Lua) -> Result<VmState> + Send>;
#[cfg(all(not(feature = "send"), feature = "luau"))]
pub(crate) type InterruptCallback = XRc<dyn Fn(&Lua) -> Result<VmState>>;

#[cfg(all(feature = "send", feature = "luau"))]
pub(crate) type DebugCallback = XRc<dyn Fn(&Lua, &Debug) -> Result<VmState> + Send>;

#[cfg(all(not(feature = "send"), feature = "luau"))]
pub(crate) type DebugCallback = XRc<dyn Fn(&Lua, &Debug) -> Result<VmState>>;

#[cfg(feature = "send")]
pub(crate) type ThreadEventCallback = XRc<dyn Fn(&Lua, crate::thread::ThreadEvent) -> Result<()> + Send>;

Expand Down
Loading