use std::ffi::{CStr, CString};
use std::os::raw::c_int;
use mlua::Lua;
use super::types::{FrameInfo, Variable};
const MAX_STACK_LEVELS: c_int = 256;
pub(crate) fn capture_stack(lua: &Lua, thread: &mlua::Thread) -> Vec<FrameInfo> {
let _ = lua; let l = thread.state();
if l.is_null() {
return Vec::new();
}
let mut out: Vec<FrameInfo> = Vec::new();
unsafe {
let what = match CString::new("Snl") {
Ok(c) => c,
Err(_) => return out,
};
let mut level: c_int = 0;
while level < MAX_STACK_LEVELS {
let mut ar: mlua::ffi::lua_Debug = std::mem::zeroed();
if mlua::ffi::lua_getstack(l, level, &mut ar as *mut _) == 0 {
break; }
if mlua::ffi::lua_getinfo(l, what.as_ptr(), &mut ar as *mut _) != 0
&& let Some(frame) = frame_info_from_ar(&ar)
{
out.push(frame);
}
level += 1;
}
}
out
}
unsafe fn frame_info_from_ar(ar: &mlua::ffi::lua_Debug) -> Option<FrameInfo> {
unsafe {
if !ar.what.is_null() {
let what = CStr::from_ptr(ar.what).to_string_lossy();
if what == "C" {
return None;
}
}
let source = if !ar.source.is_null() {
CStr::from_ptr(ar.source).to_string_lossy().into_owned()
} else {
CStr::from_ptr(ar.short_src.as_ptr())
.to_string_lossy()
.into_owned()
};
let line = if ar.currentline >= 0 {
ar.currentline as u32
} else {
0
};
let func_name = if ar.name.is_null() {
None
} else {
Some(CStr::from_ptr(ar.name).to_string_lossy().into_owned())
};
Some(FrameInfo {
source,
line,
func_name,
})
}
}
pub(crate) fn capture_variables(
lua: &Lua,
thread: &mlua::Thread,
frame_level: u32,
) -> Vec<Variable> {
let _ = lua; let l = thread.state();
if l.is_null() {
return Vec::new();
}
let mut out: Vec<Variable> = Vec::new();
unsafe {
let top_at_entry = mlua::ffi::lua_gettop(l);
let mut ar: mlua::ffi::lua_Debug = std::mem::zeroed();
if mlua::ffi::lua_getstack(l, frame_level as c_int, &mut ar as *mut _) != 0 {
collect_locals(l, &ar, &mut out);
collect_upvalues(l, &mut ar, &mut out);
}
let top_at_exit = mlua::ffi::lua_gettop(l);
mlua::ffi::lua_settop(l, top_at_entry);
debug_assert_eq!(
top_at_entry, top_at_exit,
"capture_variables must keep the VM stack balanced (push/pop symmetric)"
);
}
out
}
unsafe fn collect_locals(
l: *mut mlua::ffi::lua_State,
ar: &mlua::ffi::lua_Debug,
out: &mut Vec<Variable>,
) {
unsafe {
let mut n: c_int = 1;
while n < c_int::MAX {
let name_ptr = mlua::ffi::lua_getlocal(l, ar as *const _, n);
if name_ptr.is_null() {
break; }
let name = CStr::from_ptr(name_ptr).to_string_lossy().into_owned();
let (type_name, repr) = read_value_at_top(l);
mlua::ffi::lua_pop(l, 1); out.push(Variable {
name,
type_name,
repr,
});
n += 1;
}
}
}
unsafe fn collect_upvalues(
l: *mut mlua::ffi::lua_State,
ar: &mut mlua::ffi::lua_Debug,
out: &mut Vec<Variable>,
) {
unsafe {
let what_f = match CString::new("f") {
Ok(c) => c,
Err(_) => return,
};
if mlua::ffi::lua_getinfo(l, what_f.as_ptr(), ar as *mut _) == 0 {
return; }
let func_index = mlua::ffi::lua_gettop(l);
let mut n: c_int = 1;
while n < c_int::MAX {
let name_ptr = mlua::ffi::lua_getupvalue(l, func_index, n);
if name_ptr.is_null() {
break; }
let name = CStr::from_ptr(name_ptr).to_string_lossy().into_owned();
let (type_name, repr) = read_value_at_top(l);
mlua::ffi::lua_pop(l, 1); out.push(Variable {
name,
type_name,
repr,
});
n += 1;
}
mlua::ffi::lua_pop(l, 1);
}
}
unsafe fn read_value_at_top(l: *mut mlua::ffi::lua_State) -> (String, String) {
unsafe {
let t = mlua::ffi::lua_type(l, -1);
match t {
mlua::ffi::LUA_TNUMBER => {
let n = mlua::ffi::lua_tonumber(l, -1);
let repr = if n.fract() == 0.0 && n.is_finite() {
format!("{}", n as i64)
} else {
format!("{n}")
};
("number".to_string(), repr)
}
mlua::ffi::LUA_TSTRING => {
let mut len: usize = 0;
let ptr = mlua::ffi::lua_tolstring(l, -1, &mut len as *mut usize);
let repr = if ptr.is_null() {
String::new()
} else {
let bytes = std::slice::from_raw_parts(ptr as *const u8, len);
String::from_utf8_lossy(bytes).into_owned()
};
("string".to_string(), repr)
}
mlua::ffi::LUA_TBOOLEAN => {
let b = mlua::ffi::lua_toboolean(l, -1) != 0;
("boolean".to_string(), b.to_string())
}
mlua::ffi::LUA_TTABLE => {
let addr = mlua::ffi::lua_topointer(l, -1);
("table".to_string(), format!("table: {addr:p}"))
}
other => {
let name_ptr = mlua::ffi::lua_typename(l, other);
let type_name = if name_ptr.is_null() {
format!("type#{other}")
} else {
CStr::from_ptr(name_ptr).to_string_lossy().into_owned()
};
let repr = format!("<unsupported {type_name}>");
(type_name, repr)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::types::ThreadId;
use mlua::{HookTriggers, Lua, LuaOptions, StdLib, VmState};
use std::sync::{Arc, Mutex};
fn build_jit_off_vm() -> Lua {
let lua = unsafe { Lua::unsafe_new_with(StdLib::ALL_SAFE, LuaOptions::default()) };
lua.load("jit.off()").exec().expect("jit.off() must run");
lua
}
fn source_and_line(debug: &mlua::Debug) -> (String, u32) {
let src = debug.source();
let source = src
.source
.as_ref()
.map(|c| c.as_ref().to_string())
.or_else(|| src.short_src.as_ref().map(|c| c.as_ref().to_string()))
.unwrap_or_default();
let line = debug.current_line().unwrap_or(0) as u32;
(source, line)
}
fn find_var<'a>(vars: &'a [Variable], name: &str) -> Option<&'a Variable> {
vars.iter().find(|v| v.name == name)
}
#[test]
fn capture_variables_basic_types_by_name() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Vec<Variable>>> = Arc::new(Mutex::new(Vec::new()));
let captured_hook = Arc::clone(&captured);
let target_line: u32 = 6;
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == "@locals_chunk" && line == target_line {
let thread = hook_lua.current_thread();
let vars = capture_variables(hook_lua, &thread, 0);
if let Ok(mut g) = captured_hook.lock()
&& g.is_empty()
{
*g = vars;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let chunk = "\
local num = 42
local str = 'hello'
local flag = true
local tbl = { 1, 2, 3 }
local marker = num
return marker
";
lua.load(chunk)
.set_name("@locals_chunk")
.exec()
.expect("chunk should execute");
lua.remove_global_hook();
let vars = captured.lock().unwrap();
assert!(
!vars.is_empty(),
"capture_variables must capture locals at the breakpoint (R2.2). got empty"
);
let num = find_var(&vars, "num")
.unwrap_or_else(|| panic!("local 'num' must be retrieved by name. got: {:?}", *vars));
assert_eq!(num.type_name, "number", "num must be discriminated as number (R2.3)");
assert_eq!(num.repr, "42", "num value must be readable as 42");
let s = find_var(&vars, "str")
.unwrap_or_else(|| panic!("local 'str' must be retrieved by name. got: {:?}", *vars));
assert_eq!(s.type_name, "string", "str must be discriminated as string (R2.3)");
assert_eq!(s.repr, "hello", "str value must be readable as 'hello'");
let flag = find_var(&vars, "flag")
.unwrap_or_else(|| panic!("local 'flag' must be retrieved by name. got: {:?}", *vars));
assert_eq!(flag.type_name, "boolean", "flag must be discriminated as boolean (R2.3)");
assert_eq!(flag.repr, "true", "flag value must be readable as true");
let tbl = find_var(&vars, "tbl")
.unwrap_or_else(|| panic!("local 'tbl' must be retrieved by name. got: {:?}", *vars));
assert_eq!(tbl.type_name, "table", "tbl must be discriminated as table (R2.3)");
assert!(
tbl.repr.starts_with("table:"),
"table repr must be a readable placeholder. got: {}",
tbl.repr
);
let debug_is_nil: bool = lua
.load("return debug == nil")
.eval()
.expect("eval should succeed");
assert!(
debug_is_nil,
"std_debug must remain unexposed during FFI inspection (sandbox preserved, R5.3)"
);
}
#[test]
fn capture_variables_includes_upvalue_by_name() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Vec<Variable>>> = Arc::new(Mutex::new(Vec::new()));
let captured_hook = Arc::clone(&captured);
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, _line) = source_and_line(debug);
if source == "@upvalue_closure"
&& let Ok(mut g) = captured_hook.lock()
&& g.is_empty()
{
let thread = hook_lua.current_thread();
let vars = capture_variables(hook_lua, &thread, 0);
if vars.iter().any(|v| v.name == "captured_num") {
*g = vars;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let closure: mlua::Function = lua
.load(
"\
local captured_num = 7
return function()
local doubled = captured_num * 2
return doubled
end
",
)
.set_name("@upvalue_closure")
.eval::<mlua::Function>()
.expect("named closure factory should produce a function");
closure.call::<i64>(()).expect("closure call should succeed");
lua.remove_global_hook();
let vars = captured.lock().unwrap();
assert!(
!vars.is_empty(),
"capture_variables must capture the captured upvalue (R2.2). got empty"
);
let up = find_var(&vars, "captured_num").unwrap_or_else(|| {
panic!(
"upvalue 'captured_num' must be retrieved by name. got: {:?}",
*vars
)
});
assert_eq!(
up.type_name, "number",
"captured upvalue must be discriminated as number (R2.3)"
);
assert_eq!(up.repr, "7", "captured upvalue value must be readable as 7");
}
#[test]
fn capture_variables_unsupported_kinds_graceful_and_vm_usable() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Vec<Variable>>> = Arc::new(Mutex::new(Vec::new()));
let captured_hook = Arc::clone(&captured);
let target_line: u32 = 5;
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == "@unsupported_chunk" && line == target_line {
let thread = hook_lua.current_thread();
let vars = capture_variables(hook_lua, &thread, 0);
if let Ok(mut g) = captured_hook.lock()
&& g.is_empty()
{
*g = vars;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let chunk = "\
local basic = 42
local fnval = function() return 1 end
local nilval = nil
local marker = basic
return marker
";
lua.load(chunk)
.set_name("@unsupported_chunk")
.exec()
.expect("chunk should execute despite unsupported-typed locals (R2.5)");
lua.remove_global_hook();
let vars = captured.lock().unwrap();
assert!(
!vars.is_empty(),
"capture_variables must capture locals at the breakpoint (R2.5). got empty"
);
let basic = find_var(&vars, "basic").unwrap_or_else(|| {
panic!(
"basic-typed local 'basic' must still be obtained alongside unsupported kinds (R2.5). got: {:?}",
*vars
)
});
assert_eq!(basic.type_name, "number");
assert_eq!(basic.repr, "42");
let fnval = find_var(&vars, "fnval").unwrap_or_else(|| {
panic!(
"function-typed local 'fnval' must be RECORDED (out-of-scope), not dropped (R2.5). got: {:?}",
*vars
)
});
assert_eq!(fnval.type_name, "function");
assert!(
fnval.repr.starts_with("<unsupported"),
"an unsupported kind must carry an out-of-scope repr placeholder (R2.5): {:?}",
fnval.repr
);
let nilval = find_var(&vars, "nilval").unwrap_or_else(|| {
panic!(
"nil-typed local 'nilval' must be RECORDED as out-of-scope (R2.5). got: {:?}",
*vars
)
});
assert_eq!(nilval.type_name, "nil");
assert!(
nilval.repr.starts_with("<unsupported"),
"nil must carry an out-of-scope repr placeholder (R2.5): {:?}",
nilval.repr
);
let sane: i64 = lua
.load("return 1 + 2")
.eval()
.expect("VM must remain usable after inspecting unsupported kinds (R2.5)");
assert_eq!(sane, 3, "VM stack must remain balanced after R2.5 inspection");
}
#[test]
fn capture_stack_reports_stopped_frame_source_and_line() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Vec<FrameInfo>>> = Arc::new(Mutex::new(Vec::new()));
let captured_hook = Arc::clone(&captured);
let target_line: u32 = 2;
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == "@stack_chunk" && line == target_line {
let thread = hook_lua.current_thread();
let frames = capture_stack(hook_lua, &thread);
if let Ok(mut g) = captured_hook.lock()
&& g.is_empty()
{
*g = frames;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let chunk = "\
local a = 1
local b = a + 1
return b
";
lua.load(chunk)
.set_name("@stack_chunk")
.exec()
.expect("chunk should execute");
lua.remove_global_hook();
let frames = captured.lock().unwrap();
assert!(
!frames.is_empty(),
"capture_stack must return at least the stopped frame (R2.1). got empty"
);
let stopped = &frames[0];
assert_eq!(
stopped.source, "@stack_chunk",
"capture_stack must report the stopped frame source (R2.1). got: {:?}",
*frames
);
assert_eq!(
stopped.line, target_line,
"capture_stack must report the stopped frame line (R2.1). got: {:?}",
*frames
);
}
#[test]
fn inspection_keeps_std_debug_unexposed() {
let lua = build_jit_off_vm();
let captured = Arc::new(Mutex::new(false));
let captured_hook = Arc::clone(&captured);
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, _line) = source_and_line(debug);
if source == "@sandbox_chunk" {
let thread = hook_lua.current_thread();
let _ = capture_stack(hook_lua, &thread);
let _ = capture_variables(hook_lua, &thread, 0);
if let Ok(mut g) = captured_hook.lock() {
*g = true;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
lua.load("local a = 1\nreturn a\n")
.set_name("@sandbox_chunk")
.exec()
.expect("chunk should execute");
lua.remove_global_hook();
assert!(
*captured.lock().unwrap(),
"the hook must have run the inspection on @sandbox_chunk"
);
let debug_is_nil: bool = lua
.load("return debug == nil")
.eval()
.expect("eval should succeed");
assert!(
debug_is_nil,
"std_debug must remain nil after inspection (sandbox preserved, R5.3)"
);
}
type CoroutineCapture = (Vec<Variable>, ThreadId, Vec<FrameInfo>);
fn run_coroutine_and_capture_at(
lua: &Lua,
body: &str,
body_name: &str,
target_line: u32,
) -> (Vec<Variable>, Option<ThreadId>, Vec<FrameInfo>) {
let captured: Arc<Mutex<Option<CoroutineCapture>>> = Arc::new(Mutex::new(None));
let captured_hook = Arc::clone(&captured);
let want_source = body_name.to_string();
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == want_source && line == target_line {
let thread = hook_lua.current_thread();
let tid = ThreadId::from_state(thread.state());
let vars = capture_variables(hook_lua, &thread, 0);
let stack = capture_stack(hook_lua, &thread);
if let Ok(mut g) = captured_hook.lock()
&& g.is_none()
{
*g = Some((vars, tid, stack));
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let scene_fn: mlua::Function = lua
.load(body)
.set_name(body_name)
.into_function()
.expect("body should load into a function");
let driver: mlua::Function = lua
.load(
"\
local scene_fn = ...
local co = coroutine.create(scene_fn)
while coroutine.status(co) ~= 'dead' do
local ok, err = coroutine.resume(co)
if not ok then error(err) end
end
",
)
.set_name("@scene_driver")
.into_function()
.expect("driver should load");
driver
.call::<()>(scene_fn)
.expect("coroutine driver should run to completion");
lua.remove_global_hook();
match Arc::try_unwrap(captured)
.expect("hook dropped after remove_global_hook")
.into_inner()
.unwrap()
{
Some((vars, tid, stack)) => (vars, Some(tid), stack),
None => (Vec::new(), None, Vec::new()),
}
}
#[test]
fn capture_variables_reaches_running_coroutine_body_locals() {
let lua = build_jit_off_vm();
let body = "\
local a = 1
local b = 'x'
local c = true
local t = { 10, 20 }
local marker = a
coroutine.yield()
marker = marker + 1
return marker
";
let (vars, tid, _stack) =
run_coroutine_and_capture_at(&lua, body, "@co_body_locals", 5);
assert!(
tid.is_some(),
"the hook must have fired inside the coroutine body at the marker line (R2.4)"
);
assert!(
!vars.is_empty(),
"capture_variables must reach the running coroutine body frame's locals (R2.4). \
got empty — the body frame was UNREACHABLE (this is the R-2 failure mode)"
);
let a = find_var(&vars, "a").unwrap_or_else(|| {
panic!("coroutine-body local 'a' must be retrieved by name (R2.4). got: {vars:?}")
});
assert_eq!(a.type_name, "number", "body local 'a' must be a number (R2.3/R2.4)");
assert_eq!(a.repr, "1", "body local 'a' must read as 1");
let b = find_var(&vars, "b").unwrap_or_else(|| {
panic!("coroutine-body local 'b' must be retrieved by name (R2.4). got: {vars:?}")
});
assert_eq!(b.type_name, "string", "body local 'b' must be a string");
assert_eq!(b.repr, "x", "body local 'b' must read as 'x'");
let c = find_var(&vars, "c").unwrap_or_else(|| {
panic!("coroutine-body local 'c' must be retrieved by name (R2.4). got: {vars:?}")
});
assert_eq!(c.type_name, "boolean", "body local 'c' must be a boolean");
assert_eq!(c.repr, "true", "body local 'c' must read as true");
let t = find_var(&vars, "t").unwrap_or_else(|| {
panic!("coroutine-body local 't' must be retrieved by name (R2.4). got: {vars:?}")
});
assert_eq!(t.type_name, "table", "body local 't' must be a table");
let sane: i64 = lua
.load("return 1 + 2")
.eval()
.expect("VM must remain usable after coroutine body inspection (R2.5)");
assert_eq!(sane, 3, "VM stack must stay balanced after coroutine inspection");
let debug_is_nil: bool = lua
.load("return debug == nil")
.eval()
.expect("eval should succeed");
assert!(debug_is_nil, "std_debug must remain nil during coroutine inspection (R5.3)");
}
#[test]
fn capture_variables_reaches_coroutine_body_after_yield() {
let lua = build_jit_off_vm();
let body = "\
local seed = 5
local acc = seed * 2
coroutine.yield()
acc = acc + seed
local done = true
local sentinel = acc
return sentinel
";
let (vars, tid, _stack) =
run_coroutine_and_capture_at(&lua, body, "@co_post_yield", 7);
assert!(
tid.is_some(),
"the hook must have fired post-yield inside the coroutine body (R2.4)"
);
assert!(
!vars.is_empty(),
"post-yield coroutine body locals must be reachable via current_thread() (R2.4). \
got empty"
);
let acc = find_var(&vars, "acc").unwrap_or_else(|| {
panic!("post-yield body local 'acc' must be retrieved by name (R2.4). got: {vars:?}")
});
assert_eq!(acc.type_name, "number");
assert_eq!(
acc.repr, "15",
"post-yield 'acc' must read its LIVE mutated value 15 (not a pre-yield snapshot)"
);
let done = find_var(&vars, "done").unwrap_or_else(|| {
panic!("post-yield body local 'done' must be retrieved by name (R2.4). got: {vars:?}")
});
assert_eq!(done.type_name, "boolean");
assert_eq!(done.repr, "true");
}
#[test]
fn capture_stack_reports_running_coroutine_body_frame() {
let lua = build_jit_off_vm();
let body = "\
local a = 1
local b = a + 1
coroutine.yield()
return b
";
let (_vars, tid, stack) =
run_coroutine_and_capture_at(&lua, body, "@co_stack", 2);
assert!(tid.is_some(), "the hook must have fired in the coroutine body");
assert!(
!stack.is_empty(),
"capture_stack must report at least the coroutine body frame (R2.1/R2.4). got empty"
);
let top = &stack[0];
assert_eq!(
top.source, "@co_stack",
"top frame must be the coroutine body source (R2.4). got: {stack:?}"
);
assert_eq!(
top.line, 2,
"top frame must be the coroutine body stop line (R2.4). got: {stack:?}"
);
}
#[test]
fn thread_id_is_stable_across_resume_of_same_coroutine() {
let lua = build_jit_off_vm();
let seen: Arc<Mutex<Vec<ThreadId>>> = Arc::new(Mutex::new(Vec::new()));
let seen_hook = Arc::clone(&seen);
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == "@co_resume_stable" && (line == 1 || line == 4) {
let tid = ThreadId::from_state(hook_lua.current_thread().state());
if let Ok(mut g) = seen_hook.lock() {
g.push(tid);
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let scene_fn: mlua::Function = lua
.load(
"\
local first = 1
coroutine.yield()
local second = 2
local third = second + 1
",
)
.set_name("@co_resume_stable")
.into_function()
.expect("body loads");
lua.load(
"\
local scene_fn = ...
local co = coroutine.create(scene_fn)
while coroutine.status(co) ~= 'dead' do
coroutine.resume(co)
end
",
)
.set_name("@scene_driver")
.into_function()
.expect("driver loads")
.call::<()>(scene_fn)
.expect("driver runs");
lua.remove_global_hook();
let seen = seen.lock().unwrap();
assert!(
seen.len() >= 2,
"the hook must fire on both pre-yield (line 1) and post-yield (line 4) markers — \
proving we observed the coroutine across a resume. got: {seen:?}"
);
let first = seen[0];
assert!(
seen.iter().all(|&t| t == first),
"the coroutine's ThreadId (current_thread().state() addr) must be STABLE across \
yield/resume (StepController keys on it). got: {seen:?}"
);
let main_tid = ThreadId::from_state(lua.current_thread().state());
assert_ne!(
first, main_tid,
"a running coroutine's ThreadId must differ from the main thread's (R2.4 reached the \
coroutine, not main). coroutine={first:?} main={main_tid:?}"
);
}
#[test]
fn capture_stack_walks_nested_frames_and_resolves_func_name() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Vec<FrameInfo>>> = Arc::new(Mutex::new(Vec::new()));
let captured_hook = Arc::clone(&captured);
let target_line: u32 = 4;
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == "@nested_stack_chunk" && line == target_line {
let thread = hook_lua.current_thread();
let frames = capture_stack(hook_lua, &thread);
if let Ok(mut g) = captured_hook.lock()
&& g.is_empty()
{
*g = frames;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let chunk = "\
local outer_var = 99
local function inner()
local inner_var = 7
return inner_var
end
local result = inner()
return result
";
lua.load(chunk)
.set_name("@nested_stack_chunk")
.exec()
.expect("chunk should execute");
lua.remove_global_hook();
let frames = captured.lock().unwrap();
assert!(
frames.len() >= 2,
"stopped inside inner(), capture_stack must walk BOTH Lua frames \
(callee + caller chunk) (R2.1). got: {:?}",
*frames
);
let callee = &frames[0];
assert_eq!(callee.source, "@nested_stack_chunk");
assert_eq!(
callee.line, target_line,
"top frame must be the stopped line inside inner. got: {:?}",
*frames
);
assert_eq!(
callee.func_name.as_deref(),
Some("inner"),
"the callee frame's func_name must be resolved as 'inner' (R2.1). got: {:?}",
*frames
);
let caller = &frames[1];
assert_eq!(caller.source, "@nested_stack_chunk");
assert_eq!(
caller.line, 6,
"the caller frame must sit on the call-site line. got: {:?}",
*frames
);
assert_eq!(
caller.func_name, None,
"a top-level chunk frame has no resolvable func_name. got: {:?}",
*frames
);
}
#[test]
fn capture_variables_at_caller_frame_level() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Vec<Variable>>> = Arc::new(Mutex::new(Vec::new()));
let captured_hook = Arc::clone(&captured);
let target_line: u32 = 4;
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == "@caller_frame_chunk" && line == target_line {
let thread = hook_lua.current_thread();
let vars = capture_variables(hook_lua, &thread, 1);
if let Ok(mut g) = captured_hook.lock()
&& g.is_empty()
{
*g = vars;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let chunk = "\
local outer_var = 99
local function inner()
local inner_var = 7
return inner_var
end
local result = inner()
return result
";
lua.load(chunk)
.set_name("@caller_frame_chunk")
.exec()
.expect("chunk should execute");
lua.remove_global_hook();
let vars = captured.lock().unwrap();
assert!(
!vars.is_empty(),
"frame_level=1 must capture the caller frame's variables (R2.2). got empty"
);
let outer = find_var(&vars, "outer_var").unwrap_or_else(|| {
panic!(
"caller local 'outer_var' must be visible at frame_level=1. got: {:?}",
*vars
)
});
assert_eq!(outer.type_name, "number");
assert_eq!(outer.repr, "99", "caller local must carry the caller's value");
assert!(
find_var(&vars, "inner_var").is_none(),
"the CALLEE's local 'inner_var' must NOT appear at frame_level=1 \
(proves the caller frame, not the stopped frame, was read). got: {:?}",
*vars
);
}
#[test]
fn capture_variables_out_of_range_level_returns_empty() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Option<Vec<Variable>>>> = Arc::new(Mutex::new(None));
let captured_hook = Arc::clone(&captured);
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, _line) = source_and_line(debug);
if source == "@oor_level_chunk" {
let thread = hook_lua.current_thread();
let vars = capture_variables(hook_lua, &thread, 200);
if let Ok(mut g) = captured_hook.lock()
&& g.is_none()
{
*g = Some(vars);
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
lua.load("local a = 1\nreturn a\n")
.set_name("@oor_level_chunk")
.exec()
.expect("chunk should execute despite an out-of-range capture level (R2.5)");
lua.remove_global_hook();
let vars = captured
.lock()
.unwrap()
.clone()
.expect("the hook must have attempted the out-of-range capture");
assert!(
vars.is_empty(),
"an out-of-range frame_level must yield an EMPTY capture (graceful, R2.5). \
got: {vars:?}"
);
let sane: i64 = lua
.load("return 1 + 2")
.eval()
.expect("VM must remain usable after an out-of-range capture (R2.5)");
assert_eq!(sane, 3);
}
#[test]
fn capture_variables_number_repr_fractional_negative_and_nonfinite() {
let lua = build_jit_off_vm();
let captured: Arc<Mutex<Vec<Variable>>> = Arc::new(Mutex::new(Vec::new()));
let captured_hook = Arc::clone(&captured);
let target_line: u32 = 4;
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if source == "@number_repr_chunk" && line == target_line {
let thread = hook_lua.current_thread();
let vars = capture_variables(hook_lua, &thread, 0);
if let Ok(mut g) = captured_hook.lock()
&& g.is_empty()
{
*g = vars;
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
let chunk = "\
local frac = 2.5
local neg = -3
local huge = math.huge
local marker = frac
return marker
";
lua.load(chunk)
.set_name("@number_repr_chunk")
.exec()
.expect("chunk should execute");
lua.remove_global_hook();
let vars = captured.lock().unwrap();
let frac = find_var(&vars, "frac").unwrap_or_else(|| {
panic!("local 'frac' must be retrieved by name. got: {:?}", *vars)
});
assert_eq!(frac.type_name, "number");
assert_eq!(
frac.repr, "2.5",
"a fractional number must keep its fraction in repr (R2.3)"
);
let neg = find_var(&vars, "neg")
.unwrap_or_else(|| panic!("local 'neg' must be retrieved by name. got: {:?}", *vars));
assert_eq!(neg.type_name, "number");
assert_eq!(
neg.repr, "-3",
"a negative integer-valued number must print without a decimal point"
);
let huge = find_var(&vars, "huge")
.unwrap_or_else(|| panic!("local 'huge' must be retrieved by name. got: {:?}", *vars));
assert_eq!(huge.type_name, "number");
assert_eq!(
huge.repr, "inf",
"math.huge is non-finite and must take the non-integer formatting path"
);
}
#[test]
fn distinct_coroutines_have_distinct_thread_ids() {
let lua = build_jit_off_vm();
let seen: Arc<Mutex<Vec<(String, ThreadId)>>> = Arc::new(Mutex::new(Vec::new()));
let seen_hook = Arc::clone(&seen);
lua.set_global_hook(HookTriggers::EVERY_LINE, move |hook_lua, debug| {
let (source, line) = source_and_line(debug);
if (source == "@co_distinct_0" || source == "@co_distinct_1") && line == 1 {
let tid = ThreadId::from_state(hook_lua.current_thread().state());
if let Ok(mut g) = seen_hook.lock()
&& !g.iter().any(|(s, _)| s == &source)
{
g.push((source, tid));
}
}
Ok(VmState::Continue)
})
.expect("set_global_hook should succeed");
for i in 0..2usize {
let name = format!("@co_distinct_{i}");
let body = format!("local marker = {i}\nreturn marker\n");
let scene_fn: mlua::Function = lua
.load(&body)
.set_name(&name)
.into_function()
.expect("body loads");
lua.load(
"\
local scene_fn = ...
local co = coroutine.create(scene_fn)
while coroutine.status(co) ~= 'dead' do
coroutine.resume(co)
end
",
)
.set_name("@scene_driver")
.into_function()
.expect("driver loads")
.call::<()>(scene_fn)
.expect("driver runs");
}
lua.remove_global_hook();
let seen = seen.lock().unwrap();
assert_eq!(
seen.len(),
2,
"both coroutines' bodies must have been observed (R2.4). got: {seen:?}"
);
assert_ne!(
seen[0].1, seen[1].1,
"distinct coroutines must have distinct ThreadIds so the StepController can tell \
them apart. got: {seen:?}"
);
}
}