Handle Lifecycle
Every builder (SKLuaBuilder) and fitted model (SKLuaModel) owns an opaque C ABI pointer (*mut SKFfiBuilder or *mut SKFfiModel). Because Lua values are garbage-collected while native estimator memory lives outside Lua’s heap, sciencekit-lua provides deterministic release alongside automatic GC cleanup, guaranteeing that every handle is freed exactly once.
Option::take(), making repeated closes and subsequent GC collection safe no-ops.Three ways a handle is released
1. Explicit builder:close() and model:close()
Calling :close() frees the underlying SKFfiBuilder or SKFfiModel immediately and sets .closed = true. :close() is idempotent: calling it a second or third time returns nil without touching freed memory.
local sciencekit = require("sciencekit")
local model = sciencekit.linear_regression()
:fit({ { 1.0 }, { 2.0 }, { 3.0 } }, { 2.0, 4.0, 6.0 })
assert(not model.closed)
assert(tostring(model) == "sciencekit model (linear-regression)")
model:close()
model:close() -- idempotent no-op
assert(model.closed)
assert(tostring(model) == "sciencekit model (linear-regression) [closed]")
2. Lua 5.4 to-be-closed variables (<close>)
Both SKLuaBuilder and SKLuaModel implement Lua 5.4’s __close metamethod (MetaMethod::Close). Binding a handle with local ... <close> guarantees immediate native memory release when control leaves the lexical block — whether normally, via break/return, or because an error unwound the stack:
local sciencekit = require("sciencekit")
do
local builder <close> = sciencekit.standard_scaler():with_mean(true)
local model <close> = builder:fit({ { 1.0, 2.0 }, { 3.0, 4.0 } })
local scaled = model:transform({ { 2.0, 3.0 } })
assert(#scaled == 1)
end
-- Both `model` and `builder` are already closed here, before GC runs.
3. Garbage collector (Drop)
If neither :close() nor <close> is used, Lua’s garbage collector eventually finalizes unreachable userdata and drops the Rust wrapper (impl Drop for SKLuaBuilderHandle / SKLuaModelHandle), which invokes self.close(). If you already closed the handle explicitly, Drop sees pointer == None and does nothing.
When to close explicitly: Fitted models hold heap allocations outside Lua's GC accounting, so Lua may not feel memory pressure when a tight loop creates thousands of models. Prefer local model <close> = ... or model:close() inside loops and request handlers.
Post-close safety
Attempting any configuration setter, fit, predict, transform, score, or save on a closed handle fails immediately with sciencekit invalid-handle (1):
local sciencekit = require("sciencekit")
local builder = sciencekit.linear_regression()
builder:close()
local ok, err = pcall(function()
builder:include_intercept(true)
end)
assert(not ok)
assert(tostring(err):find("sciencekit invalid-handle (1)", 1, true))
Auditing handle leaks with sciencekit.live_handles()
sciencekit.live_handles() returns the exact count of SKLuaBuilderHandle and SKLuaModelHandle instances currently live on the calling OS thread. Combine it with two GC sweeps in test suites to assert zero leaks (from tests/run.lua):
local sciencekit = require("sciencekit")
collectgarbage("collect")
collectgarbage("collect")
local baseline = sciencekit.live_handles()
do
local model = sciencekit.linear_regression()
:fit({ { 1.0 }, { 2.0 }, { 3.0 } }, { 2.0, 4.0, 6.0 })
model:close()
model = nil
end
collectgarbage("collect")
collectgarbage("collect")
assert(sciencekit.live_handles() == baseline, "every handle was freed once")
(Why does sciencekit.linear_regression():fit(...) create two handles briefly? One for the anonymous builder and one for the returned model! Binding local builder <close> or running collectgarbage("collect") releases the temporary builder too.)