use dellingr::{ArgCount, RetCount, State};
fn install_force_gc(state: &mut State) {
state.push_rust_fn(|state| {
state.gc_collect();
Ok(0)
});
state.set_global("force_gc");
}
fn install_force_next_gc(state: &mut State) {
state.push_rust_fn(|state| {
state.gc_set_threshold(1);
Ok(0)
});
state.set_global("force_next_gc");
}
#[test]
fn active_frame_string_literal_survives_explicit_gc() {
let mut state = State::new();
state.gc_disable_auto();
install_force_gc(&mut state);
state
.load_string(
r#"
force_gc()
return "literal after gc"
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
assert_eq!(state.to_string(-1).unwrap(), "literal after gc");
state.pop(1);
}
#[test]
fn string_literals_are_unrooted_after_frame_exits() {
let mut state = State::empty();
state.gc_disable_auto();
assert_eq!(state.string_count(), 0);
state
.load_string(
r#"
local temporary = "collect this literal after return"
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
assert!(state.string_count() > 0);
state.gc_collect();
assert_eq!(state.string_count(), 0);
}
#[test]
fn open_upvalue_survives_gc_while_defining_frame_is_active() {
let mut state = State::new();
state.gc_disable_auto();
install_force_gc(&mut state);
state
.load_string(
r#"
local function outer()
local captured = {value = 55}
live = function()
return captured.value
end
force_gc()
return live()
end
return outer()
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
assert_eq!(state.to_number(-1).unwrap(), 55.0);
state.pop(1);
}
#[test]
fn executing_returned_closure_roots_closed_upvalues_during_auto_gc() {
let mut state = State::empty();
state
.load_string(
r#"
local function make_closure()
local captured = {value = 42}
return function()
local trigger_gc = {}
return captured.value
end
end
return make_closure()()
"#,
)
.unwrap();
state.gc_set_threshold(1);
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
assert_eq!(state.to_number(-1).unwrap(), 42.0);
state.pop(1);
}
#[test]
fn temporary_callable_table_survives_gc_during_call_metamethod_lookup() {
let mut state = State::new();
install_force_next_gc(&mut state);
state
.load_string(
r#"
local function make_callable()
local callable = setmetatable({ value = 73 }, {
__call = function(self)
return self.value
end
})
force_next_gc()
return callable
end
return make_callable()()
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
assert_eq!(state.to_number(-1).unwrap(), 73.0);
state.pop(1);
}
#[test]
fn closure_captured_table_survives_gc() {
let mut state = State::new();
state.gc_disable_auto();
state
.load_string(
r#"
local function make_closure()
local captured = {value = 42}
return function() return captured.value end
end
test_fn = make_closure()
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
state.gc_collect();
state.get_global("test_fn");
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
let result = state.to_number(-1).unwrap();
assert_eq!(result, 42.0, "Captured table value should survive GC");
state.pop(1);
}
#[test]
fn closure_survives_multiple_gc_cycles() {
let mut state = State::new();
state.gc_disable_auto();
state
.load_string(
r#"
local function make_closure()
local data = {count = 0}
return function()
data.count = data.count + 1
return data.count
end
end
counter = make_closure()
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
for expected in 1..=5 {
state.gc_collect();
state.get_global("counter");
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
let result = state.to_number(-1).unwrap();
assert_eq!(
result, expected as f64,
"Counter should be {expected} after GC cycle"
);
state.pop(1);
}
}
#[test]
fn nested_closures_survive_gc() {
let mut state = State::new();
state.gc_disable_auto();
state
.load_string(
r#"
local function outer()
local x = 10
local function middle()
local y = 20
return function()
return x + y
end
end
return middle()
end
nested_fn = outer()
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
state.gc_collect();
state.get_global("nested_fn");
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
let result = state.to_number(-1).unwrap();
assert_eq!(result, 30.0, "Nested closure should access both upvalues");
state.pop(1);
}
#[test]
fn metatable_as_upvalue_survives_gc() {
let mut state = State::new();
state.gc_disable_auto();
state
.load_string(
r#"
local mt = {
__index = {
get_value = function(self) return self._value end
}
}
function create_object(val)
local obj = {_value = val}
setmetatable(obj, mt)
return obj
end
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
state.gc_collect();
state.get_global("create_object");
state.push_number(99.0);
state.call(ArgCount::Fixed(1), RetCount::Fixed(1)).unwrap();
state.set_global("obj");
state.load_string("return obj:get_value()").unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
let result = state.to_number(-1).unwrap();
assert_eq!(result, 99.0, "Metatable method should work after GC");
state.pop(1);
}
#[test]
fn unreferenced_closure_is_collected() {
let mut state = State::new();
state.gc_disable_auto();
let size_before = state.heap_size();
state
.load_string(
r#"
local function make_and_discard()
local big_table = {a=1, b=2, c=3, d=4}
local fn = function() return big_table end
-- fn goes out of scope here, not stored anywhere
end
make_and_discard()
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
let size_after_create = state.heap_size();
assert!(
size_after_create > size_before,
"Heap should grow after creating objects"
);
state.gc_collect();
let size_after_gc = state.heap_size();
assert!(
size_after_gc < size_after_create,
"GC should collect unreferenced closure and table. Before GC: {size_after_create}, After: {size_after_gc}"
);
}
#[test]
fn closure_with_mixed_upvalues() {
let mut state = State::new();
state.gc_disable_auto();
state
.load_string(
r#"
local function make_closure()
local num = 42
local str = "hello"
local tbl = {x = 1}
local fn = function() return "inner" end
return function()
return num, str, tbl.x, fn()
end
end
mixed_fn = make_closure()
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
state.gc_collect();
state.get_global("mixed_fn");
state.call(ArgCount::Fixed(0), RetCount::Fixed(4)).unwrap();
assert_eq!(state.to_number(-4).unwrap(), 42.0);
assert_eq!(state.to_string(-3).unwrap(), "hello");
assert_eq!(state.to_number(-2).unwrap(), 1.0);
assert_eq!(state.to_string(-1).unwrap(), "inner");
state.pop(4);
}
#[test]
fn closure_in_table_survives_gc() {
let mut state = State::new();
state.gc_disable_auto();
state
.load_string(
r#"
local registry = {}
local function register(name, value)
local captured = value
registry[name] = function() return captured end
end
register("answer", 42)
register("pi", 3.14159)
function get(name)
return registry[name]()
end
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
state.gc_collect();
state.get_global("get");
state.push_string("answer");
state.call(ArgCount::Fixed(1), RetCount::Fixed(1)).unwrap();
assert_eq!(state.to_number(-1).unwrap(), 42.0);
state.pop(1);
state.gc_collect();
state.get_global("get");
state.push_string("pi");
state.call(ArgCount::Fixed(1), RetCount::Fixed(1)).unwrap();
let pi = state.to_number(-1).unwrap();
assert!((pi - std::f64::consts::PI).abs() < 0.0001);
state.pop(1);
}
#[test]
fn many_closures_shared_upvalue() {
let mut state = State::new();
state.gc_disable_auto();
state
.load_string(
r#"
local shared = {value = 0}
closures = {}
for i = 1, 10 do
closures[i] = function()
shared.value = shared.value + 1
return shared.value
end
end
"#,
)
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(0)).unwrap();
state.gc_collect();
for i in 1..=10 {
state
.load_string(format!("return closures[{i}]()"))
.unwrap();
state.call(ArgCount::Fixed(0), RetCount::Fixed(1)).unwrap();
let result = state.to_number(-1).unwrap();
assert_eq!(
result, i as f64,
"Shared upvalue should be incremented to {i}"
);
state.pop(1);
state.gc_collect();
}
}