Release v0.10.0 - lde
← Blog
Release v0.10.0<br>David Cruz · August 3, 2026
Upgrade to the latest version with lde upgrade!
The runtime rewrite
It has been months since 0.9.1. This release is late on purpose. Most of that time went into one thing: reworking how lde runs your code. It is a lot to do justice in one blog post, so here is the short version. lde now embeds LuaJIT the way a C program would, through the Lua C API, instead of running LuaJIT inside of itself.
Why the rewrite
The old way had a hard limit. LuaJIT has no way to isolate ffi. lde sandboxed code with setfenv, debug.sethook, and similar tools, and that handled most of Lua. It could never namespace away the ffi definitions. Definitions leaked between runs and went stale, which caused a steady stream of problems.
The stopgap was ffix. It worked, but it had to bring its own C parser just to detour and namespace LuaJIT symbols. That put overhead on the ffi library itself. Not ideal.
The fix
The real fix was to redo how lde works from the ground up. I moved to the Lua C API, the same path C and other languages use to embed Lua. The hard requirement was speed. LuaJIT’s own FFI API is not re-entrancy safe, so it could not do the job. That forced C bridges, written and then optimized repeatedly, to get a layer with minimal overhead.
The result is lua-sys, a clean API for using LuaJIT from LuaJIT:
local lua = require("lua-sys")
-- Create an independent guest Lua state<br>local state = lua.new()
-- Evaluate an expression in the guest (shorthand)<br>print(state:eval("return 1 + 2")) -- 3
-- Load a chunk, then call it: chunk(...) is shorthand for chunk:eval(...)<br>local add = state:load("return function(a, b) return a + b end")()<br>print(add(1, 2)) -- 3
-- Expose host functions to guest code. Table field access is proxied<br>-- through __index, so g.foo works instead of g:get("foo") / g:set("foo").<br>local g = state:globals()<br>g.double = function(x) return x * 2 end<br>print(state:eval("return double(21)")) -- 42
-- Plain host tables are coerced to guest tables<br>g.config = { timeout = 5, retries = 3 }<br>print(state:eval("return config.timeout")) -- 5
-- Always close when done<br>state:close()<br>And it is quite fast. Cross-state calls cost about 70 to 200 nanoseconds depending on direction and argument count:
Call pathOverheadHost → Guest (noop)~70 nsHost → Guest (2 args, 1 return)~120 nsGuest → Host callback (noop)~130 nsHost → Guest → Host round-trip~200 ns<br>The result
The result is a more stable, more isolated, and more secure lde. Packages no longer reach all of lde’s internals, and the guest state can be sandboxed further in the future. It also opens a bigger door: lde-test and the rest of the standard library now depend only on the Lua C API. Any engine that provides it, Lua 5.4 and Luau included, could run them one day.
It took months of troubleshooting, finding LuaJIT bugs, balancing support across Linux, macOS, and Windows, optimizing, and changing course more than once. Other Lua package managers have appeared since. I am aware of them. I am steadfast on getting lde to a stable 1.0 release and making up for the lost time.
What changed for you
Every program runs in a fresh, isolated guest state. lde run, lde test, lde -e, and lde ./file.lua all get a clean _G and a fresh package.loaded. Nothing leaks between runs.
Error traces and profiles show only your code, never lde’s internals. Older versions trimmed lde frames from tracebacks so errors showed less tooling. The new runtime makes that work unnecessary.
The test runner and the profiler in this release both run on top of the new runtime.
lua-sys is a regular lde package. Any project can use it:
lde add lua-sys --git https://github.com/lde-org/lua-sys<br>Test runner upgrades
Watch mode
lde test --watch re-runs your tests whenever a file in src/ or tests/ changes:
lde test --watch
The watcher monitors src/, tests/, and your lde.json and build.lua at the package root. It ignores changes under target/, so dependency installs do not interrupt the loop. From a monorepo root, it watches every package that has tests. Filters work in watch mode too:
lde test --watch "unit*"<br>Failure output with code snippets
When a test fails, lde shows the code that failed. The output includes the file and line, a highlighted snippet, and a caret under the failing assertion:
tests/fail.test.lua<br>tests/fail.test.lua:3: Expected 1 to equal 2<br>1 │ local test = require("lde-test")<br>2 │ test.it("fails", function()<br>3 │ test.equal(1, 2)<br>4 │ end)<br>│ ^^^^^<br>Tests: 1 failed, 0 passed, 1 total<br>The snippet with the caret is the new part of the output.
Filters
lde test accepts one or more glob filters. A file runs when any filter matches:
lde test "unit*"<br>lde test "unit*" "other*"<br>lde test ./tests/unit.test.lua<br>A path that starts with ./ or / is resolved against the tests directory. When run from a monorepo root, a filter that matches no files prints No files matched.
Empty suites fail
A test file that registers no...