Lua 5.4 in pure Java — no GC of its own; the JVM does the collecting.
Run existing Lua projects on the JVM without changing a single line of their Lua code.
This project is a module-by-module port of the Lua 5.4 reference implementation
(lua-5.4.x C sources) to Java. It is not a Lua-inspired language that merely looks
similar — it is Lua 5.4, with the memory-management layer removed and handed to the
host runtime.
| Target | Full Lua 5.4 language semantics + complete standard library |
| Method | Direct, module-by-module translation of the C reference sources |
| GC | Not implemented. Object lifetime is entirely the JVM's job (G1/ZGC are concurrent collectors already) |
| Runtime | Java 17+ (coroutines are plain stacks, no threads — see below) |
| Compatibility | Pure-Lua projects; C extension modules (.so / luaopen_*) are out of scope for now |
lgc.c is one of the most intricate parts of the C implementation: tri-colour
incremental marking, generational mode, write barriers, finalizers, weak tables.
All of it exists to do what the JVM already does — concurrently, and better tuned
than a hand-rolled collector written in Java would be. The file is deleted
outright. Only the semantics that depend on collection happening are rebuilt on
JVM primitives:
| Lua concept | C mechanism | This implementation |
|---|---|---|
| Object lifetime | lgc.c tri-colour marking |
JVM GC (G1 / ZGC) |
Weak tables (__mode = "k"/"v"/"kv") |
weak links swept in lgc.c |
WeakReference + ReferenceQueue |
__gc finalizers |
luaC_checkfinalizer / GCTM |
java.lang.ref.Cleaner |
| Short-string interning | lstring.c table pinned against collection |
weak intern pool; entries die when unreferenced |
Everything else — lexer, parser, code generator, VM, metamethods, coroutines, standard library — is a direct port.
The package layout mirrors the C sources one-to-one, so a reader holding
lua-5.4.x side by side can find their way around:
| Lua 5.4 C source | Java |
|---|---|
lobject.h / lobject.c |
LuaValue, LuaString, LuaTable, Proto, Closure, Upvalue |
lstring.c |
StringPool |
ltable.c |
LuaTable |
lfunc.c |
Proto, Closure, Upvalue |
lstate.c |
LuaState, CallInfo, GlobalState |
lzio.c |
Zio |
llex.c |
Lexer |
lparser.c |
Parser (hand-written recursive descent, as upstream) |
lcode.c / lopcodes.h |
FuncState, Codegen, OpCode |
lvm.c |
Interpreter |
ldo.c |
call frames, protected calls, error propagation |
ltm.c |
Meta (metamethod dispatch) |
ldebug.c |
Debug, error messages, stack traces |
lapi.c / lauxlib.c |
operations on LuaState, AuxLib |
lundump.c / ldump.c |
bytecode load / dump |
lbaselib.c, lcorolib.c, loadlib.c, lstrlib.c, ltablib.c, lmathlib.c, liolib.c, loslib.c, lutf8lib.c, ldblib.c |
corresponding lib.* classes |
lgc.c |
absent — see above |
The auxiliary layer (lauxlib.c and friends) comes along, because the standard
library is written against it.
These are the places where translating C to Java is not mechanical, and where a wrong call costs correctness that is expensive to recover later.
A Lua string may contain \0; #s counts bytes; s:byte(i) returns a byte.
Java String is UTF-16 and rejects lone surrogates. Any real-world Lua project
that touches binary data, UTF-8 text, or embedded \0 will break if strings are
backed by String. Lua strings are therefore backed by byte[], and every string
library function is implemented byte-wise.
Lua 5.4 distinguishes 1 (integer) from 1.0 (float): they compare equal, but
math.type differs, // differs from /, 1/2 is 0.5 while 1//2 is 0,
and coercion to string yields 1 vs 1.0. Values are a mutable slot type
(a type tag plus long, double and an Object payload) mirroring C's TValue
— this avoids boxing and lets the VM write stack slots in place.
Ported from ltable.c, including the migration logic that decides whether an
integer key is dense enough to live in the array part. Integer keys and string keys
are distinct entries: t[1] and t["1"] are different, as in Lua.
Lua coroutines can yield from arbitrarily deep stacks — inside pcall, inside a
metamethod, inside a for iterator. The plan was to give each one a Java virtual
thread. That turned out to be the wrong call, and phase 5 dropped it.
A Lua coroutine is cooperative: exactly one runs at a time, and the suspension
point is stated explicitly by coroutine.yield. C models this with one stack per
lua_State plus longjmp out to lua_resume. Java has the same two ingredients
— one stack per LuaState, and an exception (LuaYield) that unwinds the Java
call stack. Nothing needs a thread, because all the Lua state lives on the
heap: unwinding the Java frames leaves the stack, the CallInfo chain and the
open upvalues exactly as they were, ready to continue on resume.
Virtual threads were also too permissive. A C function that calls back into Lua
(table.sort's comparator, string.gsub's replacement function) cannot yield in
real Lua — it raises attempt to yield across a C-call boundary. On a virtual
thread it would simply work, and that difference is a bug, not a feature, for a
project whose goal is "run existing Lua code unchanged."
This also drops the Java floor from 21 to 17, which is what makes the local build work today.
The machinery that replaced threads is a faithful port of ldo.c:
lua_resume / unroll / finishCcall / finishpcallk / precover, plus
luaV_finishOp, which completes the half-executed instruction that a yield
interrupted. C functions that can be interrupted (pcall, xpcall) register a
continuation — the Java equivalent of lua_KFunction.
lua_error becomes a thrown error carrying the Lua error value. pcall / xpcall
catch it, as does the coroutine boundary. Care is needed so that non-string error
values, error objects carrying __tostring, and errors crossing a coroutine
boundary all behave as upstream does.
The Lua reference parser is hand-written recursive descent. Porting it directly removes the build-time code-generation step, along with the generated-parser drift problem the previous architecture had.
__index, __newindex, __call, __concat, __len, __eq, __lt, __le,
__unm, __add … __idiv, __close, __gc, __mode, __name, __metatable,
__pairs, __ipairs. Including the parts that are easy to get wrong: __le
degrading to not (b < a) when only __lt exists; __eq firing only for
comparable types; __len on tables ignoring non-integer results; and __index
chain traversal.
Each phase ends with tests green. Nothing advances on a red phase.
| Phase | Content | Upstream reference |
|---|---|---|
| 0 | Foundations: LuaValue, LuaString + interning, LuaTable, LuaState, error type, Zio |
lobject, lstring, ltable, lstate, lzio |
| 1 | Lexer + recursive-descent parser; emits directly as upstream does, with no separate tree | llex, lparser |
| 2 | Bytecode: full Lua 5.4 instruction set, code generator, constant folding, upvalue resolution | lopcodes, lcode, lfunc |
| 3 | Interpreter main loop: calls/returns, arithmetic & comparison with coercion, metamethod dispatch | lvm, ldo, ltm |
| 4 | Closures & open/closed upvalues, goto/labels, varargs, multiple assignment and returns |
lparser, lvm |
| 5 | Coroutines: coroutine.*, yield/resume/unroll, yield & error across boundaries |
lcorolib, ldo, lvm |
| 6 | Collection-dependent semantics: weak tables, __gc, weak string pool |
(replaces lgc.c) |
| 7 | Standard library: base, coroutine, package, string, table, math, io, os, utf8, debug |
l*lib.c, loadlib.c |
| 8 | load / loadstring / dump, require searchers, package.path / cpath |
lundump, ldump, loadlib |
| 9 | Conformance: upstream test suite + differential testing against a real lua5.4 binary |
— |
A rewrite that merely looks like Lua is worthless. Four gates, in increasing order of strength:
- Upstream test suite. The
lua-5.4.x/tests/*.luafiles (alls.lua,big.lua,strings.lua,coroutine.lua,pm.lua, …) run unmodified. This is the primary gate — upstream's own suite is far more adversarial than anything written by hand here. - Differential testing. The same script runs under this implementation and
under a real
lua5.4binary; stdout must match byte for byte. Wired into CI withlua5.4installed. - Differential fuzzing. Generated programs compared against the reference binary — this is what catches the "no crash but wrong answer" class.
- Coverage gate (JaCoCo line ratio).
A phase is done only when the gates that apply to it are green.
Phase 10 — the upstream lua-5.4.x/tests suite runs green end to end. 19
files whose output is deterministic (api, bitwise, calls, closure,
code, coroutine, db, errors, events, gengc, goto, nextvar, pm,
strings, tpack, utf8, vararg, verybig, …) match a real lua5.4
byte for byte. Five more (cstack, gc, literals, locals,
constructs) print things that vary with the implementation or with a random
seed, so they are only asserted to run to completion and print OK — the reason
for each is listed under Phase 10 below. 807 tests green, JaCoCo line coverage
0.90 against a 0.89 gate. (The upstream-suite gate needs LUA54_TESTS or
target/lua54-tests; without a test directory that whole class skips itself,
so a green run on a machine without it is 25 tests weaker than it looks.)
Phase 9 — complete, including debug.sethook. The previous fakescript
language implementation has been removed from master; see Migration below.
Phases 0 through 9 are in: the compiler produces bytecode that is
field-for-field identical to a real lua5.4's, and the VM executes it —
stdout matches a real lua5.4 byte for byte across 158 + 121 + 60 + 32 + 25
- 16 + 21 + 36 differential scripts and 2100 generated programs, and error
messages match line for line.
string.dump's output is additionally compared as raw bytes, not just as behaviour. All ten standard libraries are in (base, coroutine, package, string, utf8, table, math, os, io, debug),debugincluded in full — hooks and all.
Phase 6 is the one phase where "no GC of our own" costs real ground. Weak tables,
__gc and the string pool are all rebuilt on JVM reachability, which is not the
same thing as Lua's mark-and-sweep; three places where it cannot be made to match
are listed below under Known divergences.
Landed so far:
| Piece | Upstream | Notes |
|---|---|---|
Lua type tags |
lua.h, lobject.h |
integer/float subtypes kept as distinct variants |
LuaValue |
TValue |
mutable slot, so the VM can write stack slots in place |
LuaString |
TString |
byte[]-backed; \0, arbitrary and invalid-UTF-8 bytes all preserved |
StringPool |
lstring.c strt |
interned weakly, so unused strings do not pin the heap |
LuaTable |
ltable.c |
array part + hash part, rehash, #t borders, integral-float key normalisation |
LuaError |
lua_error |
carries a Lua value; only this type is caught by pcall |
Zio |
lzio.c |
byte input stream; EOZ is -1, bytes are unsigned |
LCType |
lctype.c |
the luai_ctype_ table copied byte for byte, so lislalpha treats _ as a letter exactly as upstream does |
Numeral |
lobject.c luaO_str2num |
int-first-then-float; strict decimal syntax, so 1d/inf/nan are rejected the way C's strtod rejects them |
Lexer |
llex.c |
full token set, all escapes incl. \z / \xXX / \u{XXX} / \ddd, long strings & comments, byte-exact error messages |
OpCode |
lopcodes.h/.c |
all 83 opcodes; order, names and luaP_opmodes verified mechanically against upstream headers |
Proto |
lobject.h, lfunc.c |
code / constants / nested protos / upvalues / locals / line info |
Arith |
lobject.c, lvm.c |
the arithmetic used by constant folding — same code path as the VM will use |
ExpDesc, FuncState, DynData |
lparser.h |
expdesc's union flattened; block/goto/variable bookkeeping |
Codegen |
lcode.c |
instruction emission, register allocation, jumps & patching, constant folding, NEWTABLE/SETLIST |
Parser |
lparser.c |
hand-written recursive descent, emitting code as it parses |
UpVal |
UpVal |
two-state (open = stack slot reference, closed = private copy) |
LClosure |
LClosure |
proto + upvalue array |
CallInfo |
CallInfo |
frame record; CIST_FRESH decides where a Lua→Lua return stops |
LuaState |
ldo.c, lstate.c |
stack, call/return, pcall, upvalue open/close, all luaG_* errors |
LuaTm |
ltm.c |
metamethod lookup & dispatch, __le fallback, to-be-closed |
Vm |
lvm.c |
the interpreter main loop; all 83 opcodes |
BaseLib, MathLib |
lbaselib.c, lmathlib.c, part of lstrlib.c |
minimal but differentially-verified |
Diff (test) |
— | shared differential driver: runs one script on both sides |
ConformanceTest, CodegenTest, ParserTest, RuntimeTest |
— | run a real lua5.4 binary as the oracle |
ClosureTest (test) |
— | Phase 4 gate: closures, goto, varargs, multiple assignment |
CClosure |
CClosure |
a C function bound to values — needed by coroutine.wrap |
LuaThread value (Lua.VTHREAD) |
LUA_TTHREAD |
a LuaState reachable as a Lua value |
CoroutineLib |
lcorolib.c |
create / resume / yield / status / wrap / isyieldable / running / close |
resume / unroll / finishCcall / finishpcallk / precover |
ldo.c |
the yield/resume state machine, no threads |
LuaKFunction |
lua_KFunction |
continuation for a C function interrupted by a yield |
LuaYield |
luaD_throw(L, LUA_YIELD) |
unwinding exception; deliberately not caught by pcall |
Vm.finishOp |
luaV_finishOp |
completes the half-executed instruction a yield interrupted |
CoroutineTest (test) |
— | Phase 5 gate: 60 differential scripts, incl. yield inside every metamethod |
FuzzTest (test) |
— | 1500 seeded programs + 600 more run inside a coroutine with random yields |
LuaTable.WeakRef |
iscleared in lgc.c |
a weak payload is the stored form; keeps the identity hash, since identityHashCode(null) is 0 and would collapse every dead key onto bucket 0 |
LuaTable.purge / rewrap |
cleartable / traverseweakvalue |
dead entries are dropped at the end of a collection round, and re-wrapped when __mode changes |
LuaState.clearDeadStackSlices |
traversethread's atomic branch |
nils [top, stack_last) on every thread, inside collectgarbage |
GcSentinel |
GCTM in lgc.c |
sentinel whose finalize resurrects the table onto a pending queue; drained on the Lua thread |
LuaState.close |
lua_close → callallpendingfinalizers |
runs __gc for every object that still has one, at exit |
WeakTableTest (test) |
— | Phase 6 gate: 20 differential scripts, split into a collection-dependent half and an always-true half |
GcTest (test) |
— | Phase 6 gate: 12 scripts — once-only, errors swallowed, late __gc ignored |
LuaUserdata |
Udata |
payload + per-object metatable; luaL_testudata needs the latter |
Undump |
lundump.c |
reads a precompiled chunk; header check, varint sizes, the binary string chunk-name rule |
Dump |
ldump.c |
writes one; string.dump, strip |
LoadTest (test) |
— | Phase 8 gate: 25 scripts — modes, chunk names, _ENV, reader functions, loadfile / dofile |
DumpTest (test) |
— | Phase 8 gate: 16 — runtime stdout and string.dump output byte for byte |
StringPack |
part of lstrlib.c |
string.pack / unpack / packsize; the only platform-dependent corner of the library |
PackTest (test) |
— | 21 scripts — integers, floats, strings, alignment, wide integers, errors, key set |
DebugLib |
ldblib.c + part of ldebug.c / ldo.c / lauxlib.c |
getinfo / locals / upvalues / traceback / registry / metatables / user values / setcstacklimit / sethook / gethook |
LuaHook |
lua.h lua_Hook |
the hook callback; DebugLib's hookf is the one sethook installs |
LuaLibs |
linit.c |
luaL_openlibs; the ten libraries in loadedlibs order |
DebugTest (test) |
— | Phase 9 gate: 36 scripts — levels, options, locals, upvalues, traceback, deep stacks, and 14 hook scripts |
LuaLibsTest (test) |
— | openAll really opens all ten, and they are the same tables the globals see |
The compiler is validated by differential comparison, not by self-declared
expectations. src/test/resources/fkdump.c is a small dumper compiled against
the reference lua5.4 sources; for every script it prints every instruction with
every field (a, b, c, k, Bx, sBx, Ax, sB, sC, sJ), plus the
line, the constant table and the upvalue table, recursively for nested functions.
CodegenTest compares that against ProtoDump's rendering of our Proto,
requiring the two to be identical.
Two corpora: ~50 hand-written scripts covering every construct, and the 33 files
of the upstream lua-5.4.7 test suite (target/lua54-tests, fetched in CI),
compiled but not run. All of them match.
Error messages are gated the same way: ParserTest writes broken sources to disk,
runs them through a real lua5.4, and compares the message byte for byte —
'end' expected (to close 'if' at line 1), label 'a' already defined on line 1,
<goto l> at line 1 jumps into the scope of local 'x', and so on. The lexer half
was already gated like this: a table of deliberately-broken sources is compared
verbatim, including the near '<token>' part whose quoting rules differ between
<eof> and everything else.
Two decisions in this phase were settled by running the reference implementation rather than by reading the manual:
- an integral float used as a key is normalised to an integer on write
(
t[1.0]yieldsmath.type(next(t)) == "integer"and#t == 1); - writing a NaN key raises
table index is NaN, while reading one silently returnsnil.
Both are locked by tests that run against real Lua 5.4 in ConformanceTest.
Each of these looks like a bug and is not; all three are locked by tests.
>>is a logical shift.intop(>>,x,-y)casts tolua_Unsignedfirst, so-8 >> 2is4611686018427387902, not-2.- Upstream's constant table contains duplicates.
ls->h— the "constant → index" cache — is shared by the whole chunk, while each function numbers its own constants from 0. A nested function overwrites cached indices with its own small ones; back in the outer function the index no longer points at the right constant, so the entry is rejected and a duplicate is appended. - Integral floats get a perturbed cache key. Lua compares table keys with
==, so100000and100000.0are the same key; withoutluaK_numberK'sr + r*2^-52trick they would evict each other from the cache and a constant would be re-created on every use.
RuntimeTest is the Phase 3 gate. Every case is a script that runs twice —
once under this implementation, once under a real lua5.4 — and the two stdout
streams must be equal. Error cases are gated on the message line, including the
(local 'x') / (constant '10') / (method 'nope') suffixes.
The stack is modelled with one invariant that everything else depends on:
A stack slot
LuaValueis never replaced.LuaStatealways doesstack[i].set(v), neverstack[i] = v.
That single rule is what makes "an open upvalue holds a reference to the stack slot" mean the same thing in Java as "an open upvalue holds a pointer to the stack slot" means in C. Break it once and closures silently stop seeing updates.
Running it today:
LuaState L = new LuaState();
BaseLib.open(L);
MathLib.open(L);
L.run("print('hello from Lua')");Each of these was guessed wrong from the manual or from intuition first; all six are locked by differential tests.
- Arithmetic does not coerce strings, and
"10" + 1 == 11does not come from the VM.lvm.c's arithmetic macros usetonumberns/tointegerns, which ignore strings. The coercion lives inlstrlib.c, which installs__add(and friends) on the string metatable — firstlua_stringtonumberboth sides, thenlua_arith. Skip those metamethods and you get a plausible but wrongattempt to perform arithmetic on a string value. - A C function's
nreturn values are the topnpushed values.ipairsauxpushesi, then pushest[i], and returns1whent[i]is nil — so the value seen by the genericforis that trailing nil, noti. Written the intuitive way (return i),for _, v in ipairs(t)never terminates, because the loop only stops when the first result is nil. - Every arithmetic opcode is followed by an
OP_MMBIN*. On success the VM doespc++to skip it; on failure it falls through and the metamethod gets dispatched. Miss onepc++and a working expression runs its metamethod too. OP_NEWTABLE'sEXTRAARGis unconditional (becausek == 0⟺Ax == 0), whileOP_SETLIST's is conditional. Reading it conditionally producesmalformed opcode EXTRAARGon every table constructor.- C's
%gstrips trailing zeros; Java's does not.1.5printed as1.5000000000000untilformatGwas written by hand. luaG_runerrorandluaL_errortake their position differently. The former usesisLua(ci)on the current frame; the latter isluaL_where(L, 1)and walks back one level to the caller. A library function that uses the former reports nosrc:line:prefix at all, because its own frame is a C frame.
FuzzTest generates 2100 programs from a small grammar and diffs each one
against the reference — 1500 plain, and 600 more whose main chunk runs inside a
coroutine.create with coroutine.yield sprinkled at random points and driven
by a bounded resume loop. Two constraints keep it from becoming a nuisance
generator:
- It must terminate. Only bounded
for/ipairsloops, nowhile, norepeat, nogoto; a call may only target a function that finished being defined earlier, so the call graph is a DAG and recursion is impossible. - It must be reproducible. Seeds increment from 0 — no
Math.random. A failure is reproducible from "seed 84".
The generator knows nothing about how we think Lua behaves, which is the point: it is the only gate that finds "does not crash, but the value is wrong".
luaF_newtbcupvalchecks__closeat assignment time, not at scope exit.OP_TBCruns right after the local receives its value, solocal x <close> = 5 print(x)raisesvariable 'x' got a non-closable valuebefore printing anything.nilandfalseare exempt (l_isfalse), solocal x <close> = nilis legal.- An error inside
__closeduring unwinding replaces the original error.error('orig')followed by a__closethat doeserror('in close')makespcallreturnin close. Keeping the original instead is wrong. - The code generator folds
OPR_MINUSandOPR_BNOTonly — neverOPR_LEN. So#7.5is a runtime error, not a load-time one. Folding it would make a script fail to compile that upstream compiles and only fails when run. - Two harness rules, learned the hard way. When a script errors, the stdout
it already produced must still be compared (the reference's stdout and stderr
are captured together, so "print ten lines, then crash" has both in its
output); and the
lua.cprogram-name prefix must be stripped everywhere, not just at the start, because it appears mid-output once stdout precedes it.
pcall,xpcall, metamethods andforiterators are yieldable; C-library callbacks are not.table.sort's comparator andstring.gsub's replacement function raiseattempt to yield across a C-call boundary, becauseltablib.c/lstrlib.ccall back throughlua_call(luaD_callnoyield).__tostringinvoked byprintis the same story. This is precisely the behaviour virtual threads would have "fixed" — and broken.- The main thread is never yieldable.
lstate.c'slua_newstatedoesincnny(L)unconditionally, socoroutine.isyieldable()isfalseon the main thread even with no C call in sight. A coroutine starts at zero, and everyresumeresets it to zero (lua_resumecopies only the low 16 bits ofnCcallsfrom the resumer). resume's arguments are the return values ofyield.lua_resume'sposcall(ci, nargs)is what makescoroutine.yield(1)see99afterresume(co, 99)— the number of values moved back is the number passed in, not the number yielded out.coroutine.wrapreports its own name, notcreate's.luaB_cowrapcallsluaB_cocreatedirectly, andluaL_argerrorderives the name from how the current frame was called — socoroutine.wrap(nil)saysbad argument #1 to 'coroutine.wrap'.luaV_finishOphas a branch per interruptible opcode, andOP_CONCAT's is not optional. Concatenation proceeds in batches, so after a__concatyield the metamethod result has to be moved back one slot,toppulled in by one, and the remaining elements concatenated — possibly yielding again. Skipping it silently yields the operand table instead of the concatenation result.
- The GC-visible stack ends at
L->top, and that is load-bearing.lgc.c: traversethreadmarks only[stack, top)and then, in the atomic phase, nils out[top, stack_last)— the source comment calls it "clear dead stack slice". So a value sitting in a register abovetopis invisible to the collector, and gets collected. Two shapes depend on this: a value pushed by a C function such asnext(the call returns,topdrops back, the slot still holds the key), and the table built by the last iteration of a loop (for i = 1, 5 do t[i] = {i} endleaves{5}in a register whileL->toponly reaches the call's arguments). Ourstackis a Java array, so every slot is a live reference —LuaState.clearDeadStackSlicesreproduces that clearance, and has to run insidecollectgarbage, not on everytopchange (see the next bullet). - …but clearing eagerly on every
topchange breaks two things. C only clears during the atomic phase. Doing it inline silently destroyed bothluaT_callTMres's result (the destination register can be the slot being popped) and the error object ofpcall(LuaError.valuealiases a live stack slot, becauseerror('boom', 0)throwsL.arg(0)itself — sopcallstarted returningfalse nil). Waiting untilcollectgarbageis both faithful and exactly as precise. - A weak table's storage has to be weak, not merely carry a
WeakReferencebeside it. The payload in the slot is theWeakReference; a strong payload means nothing is ever released. That has a consequence:LuaTable.getmust hand back a copy for weak tables, so the write fast path (OP_SETTABLE's "slot found, write into it") cannot be used on them — writing into a copy is a no-op, andt.f = gwould leavet.fequal tof. __modeis read dynamically, and re-wraps everything that is already in the table. Building a table, filling it, and then callingsetmetatable(t, {__mode = 'k'})still lets those entries be collected — measured. Since our weakness is a storage form, changing the mode has to walk the table and re-wrap in place, and a weak table must keep everything in the hash part (array slots are plainLuaValues and cannot hold aWeakRef).__gcneeds the object alive, which no JVM reference type can give you. Every "it's dead" notification (WeakReferencecleared,Cleaner,PhantomReference) arrives after the object is unreachable, and the reference cannot be recovered. Onlyfinalize()runs whilethisis still usable, and it is allowed to resurrect. So a table with__gcgets a sentinel that only it points to; when both go unreachable the sentinel'sfinalizepushes the table onto a pending queue, and the nextcollectgarbagedrains that queue on the Lua thread — never on the JVM finalizer thread, where calling into aLuaStatewould race and any exception would vanish without a trace.
These are not oversights; each is a place where the JVM has no primitive to match, and each is documented rather than papered over.
- Ephemeron tables only handle the case where the value is the key.
__mode='k'witht[k] = kclears the entry (matching the reference), and the usual cache shapecache[obj] = {fresh data}keeps its value (also matching). Butt[k] = {k}— value contains key, key otherwise unreachable — keeps the entry where Lua would drop it. The two halves are mutually exclusive in Java: holding the value strongly keeps the key alive; holding it weakly loses the value whenever the key is alive but the value is not otherwise referenced, which is exactly the common cache. Data loss is worse than retention, so we retain. - A weak entry whose key has a
__gcis dropped one cycle early. Lua marks finalizable objects, so the entry survives the cycle that schedules the finalizer. Ours keys offWeakReferenceclearing, which happens in the same cycle. - Objects with
__gcare only reclaimed whencollectgarbageruns. The JVM never tells us an object is unreachable except by collecting it, so there is no way to schedule a finalizer without an explicit cycle.
string (StringLib), table (TableLib), math (MathLib), utf8
(Utf8Lib), os (OsLib), io (IoLib) and package (PackageLib) are
ported, each with its own differential gate — StringLibTest (29),
MathLibTest (26), TableLibTest (18), Utf8LibTest (13), OsLibTest (24),
IoLibTest (35), PackageLibTest (22).
- A shared
lauxlib.clayer has to come before any library. Argument checking (checkString/checkInteger/argError/ …) is not scaffolding: the message text is part of the observable contract, and six hand-rolled copies would drift apart within a week. One detail is only right if the parameter numbering is 1-based:s:rep(nil)reportsbad argument #1, becauseluaL_argerrordecrements for a method call. math.max/math.minuselua_compare, not a numeric comparison. Somath.max("a","b")is"b", andmath.max(1,"b")raisesattempt to compare number with stringvia the failed__ltpath. They also preserve the operand's subtype:math.max(1, 2.0)is2.0(a float).math.fmod's float path is C'sfmod, not Lua's%. They differ in the sign of the result:math.fmod(-7.5, 2)is-1.5(sign of the dividend) while-7.5 % 2is0.5(sign of the divisor). Reusing the VM'sArith.modfhere is the easy mistake and it is wrong by a sign.math.log's base-2 and base-10 branches are genuinely different computations. C callslog2/log10;log(x)/log(2)differs fromlog2(x)on 9 of 43 sampled inputs, andlog(x)/log(10)fromlog10(x)on 25 of 43. Java has nolog2, so it is reconstructed ase + log(m)/ln2from the mantissa decompositionx = m * 2^e, which lands within 1 ulp on 190 of 192 inputs.table.inserton an object with__newindexmust call it.ltablib.cdoes not useluaH_setint; it useslua_seti, which goes throughluaV_finishset. Writing raw leavestable.insert(ud, v)silently doing nothing — no error, no write.os.executemust spawnsh, not/bin/sh. C'ssystem()isexecl("/bin/sh", "sh", "-c", cmd)— argv[0] issh— and the shell prefixes its diagnostics with argv[0]. Spelling the full path turnssh: 42: command not foundinto/bin/sh: 42: command not found, which the differential gate sees immediately.- A userdata's metatable lives on the object, not on its type.
luaL_testudataasks "is this userdata's metatable the one registered underFILE*?", so file handles cannot share a per-type metatable with the internal ones (the PRNG state,gmatch's state) without those being mistaken for files.LuaUserdatawas added for this, and with it__gc— otherwise a file left un-close()d by a script never gets closed at all. ioon a real file must be block-buffered, andio.writemust not be. C stdio buffers file output: afterio.output(p):write("x"), readingpback gives nothing, because the bytes are still in theFILE*. Standard streams are the opposite — they are line/unbuffered, andio.write's output has to reach whichever stream the host installed. SoIoLibbuffersRandomAccessFilewrites (flushing onflush,seek, any read, and close) and writes straight through for stdin/stdout/stderr.require's searcher result is tested withlua_isstring, not "is a string". A searcher that returns the number42has its value concatenated into the not-found message; only a non-string, non-number result is treated as "this searcher said nothing" and drops the\n\tprefix again.- Load-time errors run the chunk name through
luaO_chunkidtoo. The module file is compiled as"@" + path, butluaG_addinfostrips the@before printing, so a syntax error in a required module reads/tmp/m.lua:1: syntax error near 'is'— not@/tmp/m.lua:1: .... - Every library has to be registered in
_LOADED, not just in_G.coroutinewas the one that slipped through:require("coroutine")reportedmodule 'coroutine' not found, because the table was only in the globals.
sin / cos / tan / asin / acos / atan / exp / log / log10 /
sinh / cosh / tanh / pow are not bit-identical to the reference.
Java's Math is fdlibm; the reference binary links the platform libm (macOS
libsystem_m here). Both are correct to within 1 ulp of the true value, but
they round differently in the last place: of 43 typical inputs, sin differs on
1, tan on 12, cosh on 6. Closing that gap means shipping a libm of our own,
which neither fits the goal nor matches what Lua itself guarantees — Lua's own
results shift with the platform. The differential cases therefore compare these
functions to 14 significant digits (1 ulp is ~2.2e-16 relative). Everything
that can agree does agree: sqrt, abs, floor, ceil, frexp, ldexp
and the log branches above are bit-exact.
file (%p).f_tostringprints a pointer, soprint(io.stdout)can never match byte for byte.IoLibemits a same-shaped hex value and the differential harness normalises0x…away.stdinseeking. The reference's stdin in the harness is redirected from/dev/null, which is seekable (lseekreturns 0); ours is a byte array, which is not.io.stdout:seek()agrees (nil, "Illegal seek", 29on a pipe);io.stdin:seek()is not compared, because the difference is in where the stream comes from, not in whatiodoes.
loadlib.c's fallback branch is the honest one here: there is no dlopen on
the JVM (reaching it would mean JNI or FFI — a different project), so the C
searcher behaves the way C does on a platform built without dynamic loading.
package.loadlibreturnsnil, "dynamic libraries not enabled; check your Lua installation", "absent". The reference binary is built withdlopen, so it returns adlerror()string and"open".requireof a module whose.soactually exists stops at "dynamic libraries not enabled" instead of loading it. Modules that only exist as.lua, andpackage.preloadentries, are unaffected.
load / loadfile / dofile (BaseLib) and the binary-chunk pair
lundump.c / ldump.c (Undump / Dump) are in, with two differential
gates: LoadTest (25) and DumpTest (16).
DumpTest compares at two granularities, and the second one is the real
gate for this phase:
- Runtime. Each script's stdout is compared byte for byte, as everywhere else.
- Artefact.
string.dump(f)is written to a file by each side and the two files are compared byte for byte. Runtime-only comparison would let "the compiler emits something slightly wrong that still happens to run" through; the artefact comparison pins down constant ordering, the line tables, local and upvalue debug info, andmaxstacksizeas well.
Eight shapes are checked that way — plain, nested, vararg, upvalue, goto,
loop, assorted constants, and a constant string longer than
LUAI_MAXSHORTLEN (which takes the non-interned long-string path on both the
dump and the undump side).
luaU_undumprewrites the chunk name before it ever reports an error. A leading@or=is dropped; a name that starts with the signature byte (i.e. the chunk name is the binary chunk, which is whatload(s)uses when no second argument is given) becomes the literalbinary string. Soload("\27Lua")reportsbinary string: bad binary format (truncated chunk)— not an ESC-prefixed mess.f_parserconsumes the first byte on the binary path but hands it back on the text path.luaU_undump's comment says so directly: "skip 1st char (already read and checked)". Ungetting it beforeundumpmisaligns the whole header by one byte and turnstruncated chunkintonot a binary chunk.luaO_chunkidhas a third form, and it is the oneloadhits. Only@xand=xare stripped; anything else is wrapped as[string "x"].load("x++")reports[string "x++"]:1: syntax error near '+'— a "strip the prefix" implementation that skips the wrapping reportsx++:1:, and every load-time error message is then wrong.- Chunk names are byte strings, and
luaO_chunkidtruncates by bytes. Decoding to a JavaStringfirst and truncating there gets the length wrong on non-UTF-8 names.chunkIdtherefore has abyte[]core and theStringoverload goes through it. - A prototype with no source gets no position at all — not even a line
number.
luaG_addinfoisif (src) ... else return msg. This is observable:string.dump(f, true)strips the source, sopcall(load(string.dump(f, true)))reports the bareboom. Falling back to the chunk name not only adds a prefix, it splices the raw binary chunk into the message (invalid UTF-8, so it decodes to replacement characters). lundump'sdefault: lua_assert(0)is a no-op.lua_assertcompiles to((void)0)in a default build, so a chunk whose constant type byte is99loads fine and that constant is justnil. Raising an error there is inventing a semantic of our own. Measured, not assumed: the hand-built chunk loads as afunctionon the reference.
One more, on the dump side: dumpFunction compares sources by reference
(f->source == psource), not by content. Short strings are interned so the two
agree, but a long source name is not interned and C writes it out again for
every nested function. Comparing by content would emit less than C does.
Ported as StringPack (the pack/unpack half of lstrlib.c), with PackTest
(21 scripts). This is the only part of the standard library whose output
depends on the host platform: l / L are C's long, T is size_t, !'s
default maximum alignment is offsetof of LUAI_MAXALIGN, and the default byte
order is the host's. Those are pinned to the platform the reference binary runs
on — LP64 + little-endian — because on a platform where long is 4 bytes the
reference itself emits different bytes.
Two more things the reference binary settled here:
Xconsumes the option after it.getdetailscallsgetoptionto learn the next option's alignment, and that call advances the format string. Sostring.packsize("Xi4")is0, andstring.pack("Xi4", 7)is"". An implementation that peeks without advancing returns4— which looks more sensible and is wrong.str_pack'slua_pushnilmarker is observable. It exists to separate the arguments from the string buffer, but it sits at index 2, so with no second argumentstring.pack("c2")reportsstring expected, got nilrather thangot no value.
ldblib.c is in as DebugLib, together with the parts of ldebug.c that only
the introspection API needs (lua_getstack / lua_getinfo / luaG_findlocal /
luaG_traceexec / luaG_tracecall) and lauxlib.c's luaL_traceback.
DebugTest (36 scripts) is the gate. All sixteen debug functions are in.
LuaLibs.openAll (linit.c's luaL_openlibs) also landed — before it existed,
"open the standard libraries" was ten calls that only the test driver knew the
order of.
sethook is not a library function that can be ported on its own: it is a hook
dispatcher whose call points are spread over the interpreter. Porting it meant
adding, in addition to DebugLib.dHook (ldo.c: luaD_hook):
| Call point | Upstream | Fires |
|---|---|---|
LuaState.precallC |
ldo.c |
call for C functions — before the C function itself runs |
DebugLib.dHookCall from Vm's startfunc and from OP_VARARGPREP |
ldo.c: luaD_hookcall |
call / tail call for Lua functions |
DebugLib.rethook from LuaState.poscall |
ldo.c: rethook |
return for both kinds |
DebugLib.traceExec from Vm's vmfetch |
ldebug.c: luaG_traceexec |
line and count |
plus trap on CallInfo and hook / hookmask / hookcount / allowhook /
oldpc on LuaState. The point of trap is that it is a local variable in
luaV_execute, so with hooks off the main loop does not even branch.
lua_sethookmust callsettraps. The main loop'strapis a local that is only re-read from the frame at a few points (updatetrap). Turning a hook on mid-execution therefore does nothing at all unless every live Lua frame'strapis set to 1 — which is whatsettrapsdoes. Without it,debug.sethook(f, "l")produces zero line events.- A hook error is not caught by the
pcallit is reported for. Thecallhook forpcallfires insideprecallC, beforepcallestablishes its recovery point, sopcall(function() ... end)cannot catch an error raised by acallhook onpcallitself. CIST_HOOKEDis tested beforeisLuainfuncnamefromcall. The frame carrying the mark is often a C frame (thecallhook forpcallis reported onpcall's own C frame), so testingisLuafirst never reaches it — and the traceback line changes fromin hook '?'toin function <file:line>.- Hooks can never yield.
hookfcalls the user function withlua_call, which goes throughluaD_callnoyield, so the non-yieldable counter is non-zero inside a hook. Measured:coroutine.yield()in a hook reportsattempt to yield from outside a coroutineon the main thread andattempt to yield across a C-call boundaryin a coroutine — in both cases the program or coroutine dies. This is why the port does not implementCIST_HOOKYIELDat all: it is unreachable from Lua. (CIST_HOOKYIELDexists upstream for C hooks installed via the C API, which are called directly rather than throughlua_call.)
allowhook must be restored at the protected-call boundary, after the
message handler has run — not before. The handler runs at the throw point, where
allowhook is still 0 (that is what stops a traceback from re-entering the
hook). Restoring it earlier means the traceback triggers the hook again, which
raises again, which applyErrMsg turns into error in error handling. The
symptom: debug.sethook(function() error("x") end, "c") followed by an uncaught
error printed error in error handling instead of a real traceback.
- Level 0 is the C frame of
getinfoitself.lua_getstack(L, 0, &ar)succeeds and yieldswhat = "C", currentline = -1, name = "getinfo". The name comes fromfuncnamefromcallreading the caller's frame, so it answers "what was the caller calling", not "what is this frame called". Getting this off by one shifts everydebug.getinfo(n)result by a level. 'f'and'L'needlua_rotate, not a pop. The result table is pushed afterlua_getinfo, solua_getinfo's own outputs sit underneath it.treatstackoptionislua_rotate(L, -2, 1)followed bylua_setfield(L, -2). DoingL.top--instead pops the result table itself — the symptom isdebug.getinfo(1)returning something that prints as a function, anddebug.getinfo(1).funcholding the table.pushfuncnametries a global name first.errorisglobal 'error'as far asgetobjnameis concerned (it is aGETTABUP _ENV), but a traceback printsfunction 'error'—pushglobalfuncnamelooks the function up by value in_LOADED._Gand wins. Checking onlyar.namewhatgets the qualifier wrong on every C function that has a global name.- The message handler runs before the stack unwinds —
luaG_errormsgcalls it at the throw point, not fromluaD_pcall's recovery. This is the whole reasonxpcall(f, debug.traceback)shows the full traceback instead of just the outer two frames. Java has nolongjmp, but the Lua stack (stack/top/ci) is still intact at thecatch, so running the handler at the top ofpcall's catch is equivalent. LEVELS1/LEVELS2are 10 and 11, not a round 10/10, and the head and tail are not the same length. The "(skipping N levels)" count is off by one otherwise, and only on stacks deep enough to trigger it.
OP_TESTSET's condition was inverted, which brokeand/or. Upstream testsl_isfalse(rb) == k; this port testedtoBoolean() == (k != 0)— and those two are exact opposites overk's two values. It is a quiet bug:TESTSETonly survives when the result register differs fromB(patchTestRegrewrites it toOP_TESTotherwise), soif x thenwas always right whilex and ywas always wrong. The symptom isprint(t and "yes")printing the table, and — worse —cond and a or b, the most common idiom in Lua, taking the wrong branch. The bytecode gate could not see it: the emitted code was field-for-field identical to the reference's.RuntimeTest.logicalOperatorsReturnValuesnow pins the returned value, not just the truthiness.xpcall's handler ran after the call chain had already been restored. See the fourth bullet above;xpcall(f, debug.traceback)'s traceback was two frames long instead of seven.
lua.c runs the script from inside pmain, a C function, so an extra C
frame sits under the main chunk. It shows up as the trailing [C]: in ? in every
traceback and as debug.getinfo(3).what == "C". A host that calls
luaL_dostring directly has no such frame and sees neither. This is therefore
fixed in the differential driver (Diff now mirrors lua.c: pmain +
docall + msghandler), not in the engine — bending the engine to match would
have made luaL_dostring wrong instead.
The heaviest part of the fourth gate: UpstreamSuiteTest runs the upstream
.lua files unmodified and compares their output with the reference binary
byte for byte; UpstreamSmokeTest collects the ones that must not be
compared that way but still have to finish.
| File | Why it cannot be compared byte for byte |
|---|---|
cstack.lua |
each case prints a final count = how much C stack one nesting level eats; Java and C necessarily differ |
gc.lua |
the whole file tests the incremental collector's count / step / setpause; this port has no such layer |
literals.lua |
the trailing locale section needs os.setlocale("pt_BR") to actually take effect; the JVM cannot |
locals.lua |
tracegc writes a progress dot from a finalizer with io.stderr:write'.'; when it runs is up to the JVM |
constructs.lua |
line 319 is _ENV.GLOB1 = math.random(0, 1); two processes can never share a seed |
sort.lua |
it prints sort timings and a comparison count, and the input is generated with math.random |
math.lua |
it prints random seeds, the range of the random draws, and timings |
__closecan still yield after an error. Whenyieldable(L)holds,lua_pcallkdoes not install a local recovery point: the error travels all the way tolua_resume, andprecover→finishpcallk→luaF_close(..., yy = 1)finishes the job — so every__closemay yield, and a new error raised by a__closereplaces the old one for the variables still pending. Inlocals.lua's "yielding inside closing metamethods", three<close>variables yield in turn; the middle one raiseserror(err + 20)after yielding, so the third sees (andpcallfinally returns) 30, not 10. This port uses Java exceptions instead oflongjmp, so the recovery point sits inpcall'scatchandyyhas to be passed down explicitly —pcallpicks it fromyieldable(), which is exactlyk != NULL && yieldable(L).- When a yield interrupts the unwinding, somebody has to keep the error
object. In C it is always on the stack (
luaD_seterrorobjrewrites it before every close, andfinishpcallkreadsL->top - 1). Here it lives in the exception and vanishes with the Java stack, so it is parked inLuaState.closeErrPendinguntil the nextfinishPcallK. It must yield to a newer error, or the third variable would still see the stale 10. luaB_errorhas to be written in the C shape,lua_settop(L, 1)included: that call decides what sits one slot below the top, which is whatluaD_seterrorobj'sdefaultbranch reads. Without it, errors out ofcoroutine.wrap(...)lose theirfile:line:prefix.luaL_whereandluaG_addinfoanswer "no debug info" in opposite ways. The former needscurrentline > 0and otherwise emits nothing; the latter prints?:-1:anyway. Merging them breaks botherror("x")and the^%?:%-1:assertion inerrors.lua.collectgarbagevalidates its option first, and"generational"/"incremental"return the previous mode as a string (lbaselib.c: pushmode), not an integer.luaD_shrinkstack'sstackinuseis the max oftopand everyci->top, nottopalone, andstackErris cleared only when a shrink actually happens.- The main thread starts one slot up (
stack_initreserves it forbase_ci.func),IoLibmustlua_popafter creating the std files, andDiffmust pushmsghandleronto the stack the waylua.c: docalldoes — each is worth one level of recursion inerrors.lua's "expected stack overflow after N calls".
Both files used to crash on entry; they only reach OK after these. None of them
is a "we differ" — all four were plain mistakes on our side:
tunpack's overflow test must compare as unsigned. In C,nislua_Unsigned, so a wrappinge - ibecomes a huge value; a Javalongwraps to a negative one, the signed comparison lets it through, and the loop then pushes fromminItomaxIuntiltable.unpackdies withOutOfMemoryError.top + noverflows incheckStack/checkStackOk. With annof2^31the sum goes negative, so the "overMAXSTACK" test stops firing — sameOutOfMemoryError. Now computed aslong.tmove's test must not be simplified. C askse < LUA_MAXINTEGER + f, note - f < LUA_MAXINTEGER: the two agree only when nothing wraps, and wrapping is exactly what is being tested. Simplified,table.move({}, minI, maxI, 1)reports#4 (destination wrap around)where the reference reports#3 (too many elements to move).luaG_tointerrorcarries avarinfosuffix. Formath.huge << 1the reference saysnumber (field 'huge') has no integer representation.tonumbermust not useString.trim(). Java'strimstrips "characters at or below U+0020", which includes\0; C recognises onlySPACECHARS(" \f\n\r\t\v") and additionally requires the parse pointer to land exactly ons + l. So bothtonumber('1\0')andtonumber('1\0', 2)silently returned1.b_str2intis now written over bytes and re-checks the length.
sort.lua lives in the smoke set — it prints timings, so its output can never be
compared byte for byte — and a later full run failed inside it with
attempt to compare nil with number out of table.sort. The file seeds nothing,
so math.random() hands it fresh data on every run, which makes this a latent
bug: it only surfaces when a partition comes out unbalanced enough for auxsort
to draw a new random pivot.
IdxTisunsigned int, and so is the pivot seed. C'srnd % (r4 * 2)is an unsigned modulo, always in[0, r4*2). Java'sintis signed, and the seed carries exactly the 32 bits C would have produced — negative half the time. A signed modulo then dragspbelowlo, and at the extreme below1, wherea[p]is nil andsort_compreportsattempt to compare nil with number. NowInteger.remainderUnsigned, matching C.rndis passed by value. In C it is a parameter ofauxsort, so re-randomizing inside a recursive call never leaks back to the outer frame. It used to be shared through anint[]here, which meant a seed drawn deep in one subtree went on to steer every remaining partition of the whole sort.
One run in twenty is a bad way to find a bug, so choosePivot now carries its own
deterministic test (TableLibTest.choosePivotStaysInsideTheInterval) that checks
the C lua_assert — lo + r4 <= p <= up - r4 — directly, instead of hoping a
50 000-element sort trips over it.
ldo.c: f_parser reads one byte. If it is the first byte of LUA_SIGNATURE, the
chunk goes to luaU_undump; otherwise to luaY_parser. That single test is why a
caller never has to say whether it is handing over source or bytecode — and why
load, loadfile, dofile and require all accept precompiled chunks for free.
The dispatch used to exist only inside BaseLib's load / loadfile. lua_load
itself (LuaState.load) did not look at the signature, so require of a
precompiled module failed:
error loading module 'mod' from file '/tmp/mod.lua':
/tmp/mod.lua:1: unexpected symbol near '<\27>'
The reference loads it and returns the module. PackageLib reaches the loader
through LuaState.loadFile, which is why this showed up there and not in load.
The fix is at the lua_load layer, not a patch on require:
LuaState.load/loadFilenow dispatch onLua.LUA_SIGNATURE0;loadFilestill runs thelauxlibpreamble first (BOM,#!), because a precompiled chunk never starts with#— order matters;- the binary branch does not push the first byte back:
luaU_undumpsays "skip 1st char (already read and checked)", socheckHeaderstarts at byte two.
BinaryChunkTest covers both directions:
- chunks produced by the real
luac(ReferenceLua.compile, located viaLUAC54_BINor sitting next to the referencelua), run on both sides and compared byte for byte. Self-compiled chunks would be grading our own compiler with our own compiler, so the reference'sluacis what produces them. The corpus covers upvalues, varargs,goto, mixed table constructors, integer and float edges, coroutines, a 300-entry constant table, and two scripts that fail on purpose — the error messages carry line numbers and local-variable names, so losing the debug info inundumpturns them red; - chunks produced by our own
string.dump(require,dofile,loadfile, themodeargument, thebinary stringchunk-name rule, and truncated or tampered chunks, which must be load-time errors rather than crashes).
The generator had been emitting programs that do not compile:
- table constructors wrote
{'key0' = v}. Only a name can be a bare key, so a string key needs[k] = v— the numeric case was bracketed, the string case was not; expressionemitted(select('#', ...))unconditionally, while a function declares...only 40% of the time. Outside a vararg function that iscannot use '...' outside a vararg function.
Measured with the reference luac: 178 of 200 seeds failed to compile. A
program that does not compile produces the same error on both sides, so the
comparison "passes" while proving nothing — the strongest gate had been quietly
vacuous over tens of thousands of seeds. Fixed (keys are always bracketed; the
generator tracks whether the function being built is vararg), and re-measured:
0 / 400.
FuzzTest.assertCompiles now makes that state unrepresentable: it fails the seed
whose reference output has the shape of a compile error. The discriminator is
"the message ends in near 'tok'" rather than "contains expected", because
runtime errors are full of expected (bad argument #1 to 'upper' (string expected, got nil)) and those are supposed to happen.
Ordinary fuzzing only ever takes the text path — Dump and Undump were touched
by a few dozen hand-written cases, which is exactly the blind spot the
require-a-precompiled-chunk bug lived in. generatedChunksSurviveDumpAndUndump
closes it: the generated body is wrapped in a vararg function, string.dumped,
loaded back and run. That pushes thousands of random Protos through
serialization — constant-table order and varint encoding, line info, local and
upvalue debug names, maxstacksize, nested protos. A sentinel print
(dumped true loaded true) is asserted on, so a silent string.dump failure
cannot masquerade as agreement.
The fuzz generator learned table.* and immediately paid for itself: five
divergences, none of which any hand-written case had hit.
l_str2int's accumulator islua_Unsigned. Inlobject.cthe running valueais unsigned, and the overflow guard isa >= MAXBY10. With a signedlong, onceapasses 2^63 it goes negative and the guard stops firing, so the overflow escapes:1 + (math.mininteger .. 0)— the operand is"-92233720368547758080"— wraps to exactly 0 and returns1instead of falling through to the float path (-9.2233720368548e+19). NowLong.compareUnsigned.luaG_runerroraddsfile:line:where it is raised, not later.idivandmoddivide-by-zero go throughluaG_runerror, which callsluaG_addinfoon the spot. Ours threw a bareLuaErrorthat only received its prefix at the protection boundary, by which time the raising frame was gone — socoroutine.resumereturned the error as a value and it arrived without a position:false attempt to divide by zeroinstead offalse file:line: attempt to divide by zero.Arith.rawArithnow takes aLuaStatefor exactly this reason.luaH_newkeyrefuses nil values. Its first line isif (ttisnil(value)) return; /* do not insert nil values */. Assigning nil to a key that does not exist creates no node and triggers no rehash. Creating the node changes the computed array-part size, which is a direct input to#t:table.move({[1]=4.0}, 0, 2, 1)leaves#t == 2in the reference and0otherwise. The nil-key and NaN-key checks sit before that line, sot[nil] = nilmust still raisetable index is nil.lauxlib.c: interrortestslua_isnumber, not the type.luaL_checkintegerthat cannot get an integer reportsnumber has no integer representationwhenlua_isnumber(L, arg)— true for a string that parses as a number — andnumber expected, got stringotherwise. Sostring.format("%d", "3.5")is the former ("3.5"parses, 3.5 just is not an integer) andstring.format("%d", "abc")is the latter. The guard used to bev.isNumber(), which made both the latter.%.14gmust round once, from the exact value. Java'sString.format("%.Nf")does not round the value: it roundsDouble.toString's shortest representation, i.e. twice.math.sqrt(math.maxinteger)is exactly3037000499.976049900054931640625, whose shortest form is3037000499.97605; the 15th digit is a 5, so the second rounding carries and produces3037000499.9761. The reference prints3037000499.976.StringLibalready formatted throughBigDecimal(whosenew BigDecimal(double)is the exact binary expansion);luaO_tostringhad its own copy built onString.format. Two copies of one C conversion is how they drifted, soVm.formatGnow delegates toStringLib.generalDigits.
That last one is worth stating plainly: measured over 4602 printed floats, the
old code was wrong on 12 — 0.26%. No hand-written case was ever going to find
it, and no amount of upstream-suite greenness would have either. It took the
fuzzer. RuntimeTest.floatFormattingMatchesReferenceAcrossMagnitudes now pins it
with a few thousand values, generated by an LCG inside the script so that the
random-number generator itself stays out of the comparison.
The generator then learned utf8.*, gmatch, next, string.pack/unpack,
table.pack, non-string error values, math.ult/fmod/max/min, and
rawequal/rawlen/select. Measured before trusting it: every new branch fires
in 6–29% of programs, so the corpus is actually alive. It immediately found two
more, both in ltable.c — the same file as three of the previous five.
-
luaH_getnanswerslimit - 1before it ever binary-searches. The manual only asks#tto return a boundary, which makes "we found a valid boundary" sound sufficient. It is not: real programs observe which one.ltable.chas a fast path for the common case (t[#t] = nil, then ask for the length again):if (limit > 0 && isempty(&t->array[limit - 1])) { if (limit >= 2 && !isempty(&t->array[limit - 2])) return limit - 1; /* (1a) do not search */ else { /* (1b) */ ... binsearch ... } }
An array of
[1, nil, 3, nil]: binary search returns1(it finds the first boundary), the reference returns3.table.pack(1, nil, 3, nil)leaves#t == 3, not1. Across a systematic sweep of hole patterns — every single hole up ton = 32, every hole pair up ton = 19, every triple up ton = 11, all 32table.packnil combinations — 212 of 4968 results were wrong (4.3%). -
luaH_resizemust let the hash part shrink to dummy. Afterrehash, C'sreinsertmoves the old entries withluaH_set, so any integer key<= asizeis routed into the new array part and occupies no hash slot;luaH_resize(L, t, 4, 0)therefore ends with a dummy (zero-slot) hash. This port sized the hash to fit all the moved entries, so an array of 4 came with 2 stale hash slots. That matters becauseluaH_newkeytestsisdummy(t): with a non-dummy hash, the next insert just takes a free slot and never rehashes, so the array part stops growing.t = {}; t[2]=2; t[3]=3; t[4]=4; t[6]=6; t[7]=7gives the reference an array part of 8 and#t == 7; this port had 4 and 4. The sweep above is what exposed it — the previous corpus only ever inserted keys1..nin order, which never reaches this path.
Both are pinned by RuntimeTest.tableLengthBoundaryMatchesReference, which builds
the hole patterns with loops inside the script (an LCG would put the generator
itself into the comparison) and reads #t twice per table, because C rewrites
alimit as a hint on the first call.
The same corpus was then also run through coroutine.create, which found a bug
that had nothing to do with coroutines:
pcall(math.random, 5, 1)inside a coroutine reportedbad argument #1 to '?' (interval is empty); the reference reportsbad argument #1 to 'math.random' (interval is empty).
-
_LOADEDlives atglobal_Statelevel, not per-thread.lauxlib.c'spushglobalfuncnamereverse-looks-up the current function inpackage.loaded, two levels deep, to turn'?'into a real name:static void pushglobalfuncname (lua_State *L, lua_Debug *ar) { ... lua_getfield(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE); if (findfield(L, L.top - 1, 2)) { ... }
LUA_LOADED_TABLEis a field of the registry, and the registry belongs toglobal_State— everylua_Stateshares one. This port had madeloadeda per-LuaStatetable, so a coroutine'spackage.loadedwas empty and every diagnostic inside it degraded to'?'. The fix is one line in the constructor:loaded = main.loaded. Note that this only shows up inside a coroutine — the main thread populated its own table, which is why the whole upstream suite and every earlier fuzz seed were silent about it.
RuntimeTest.libraryFunctionNamesResolveInsideCoroutines pins it.
A systematic sweep of string.format("%q", ...) — every byte 0–255, in four
positions — was the first probe that wrote non-UTF-8 bytes to stdout. It found
nothing wrong with the engine, and something wrong with the harness: both sides
captured the reference's and this port's output into a Java String by decoding
UTF-8. Any invalid byte becomes U+FFFD, so 0x80 and 0xEF 0xBF 0xBD compare
equal:
>>> b'"\x80"\n'.decode('utf-8', 'replace') == b'"\xef\xbf\xbd"\n'.decode('utf-8', 'replace')
True
>>> b'"\x80"\n'.decode('latin-1') == b'"\xef\xbf\xbd"\n'.decode('latin-1')
False
Measured rather than assumed: with print deliberately changed to round-trip
through a Java String (i.e. corrupting every non-UTF-8 byte), the new %q
sweep is red under Latin-1 and green under UTF-8 — the old gate let a real
byte-level corruption through. The gate now decodes Latin-1, which is a
byte-for-char bijection, so comparisons are genuinely byte-exact.
RuntimeTest.quoteFormatMatchesReferenceForEveryByte is the pin.
The next probe was a full matrix: every operator × every pair of types, with no
metamethods, comparing the error text. 1651 lines. A second one did the same
with metamethods present — which of the two handlers runs, __le falling back to
not (b < a), __index/__newindex in both table and function form, __concat
right-associativity, __close timing and error propagation (245 lines). The
second one was clean; the first found this:
-
luaG_tointerrorconverts withF2Ieq, not "floor to integer".lvm.hdefinesLUA_FLOORN2IasF2Ieqby default — the name says floor, the semantics are "accepts only integral values":void luaG_tointerror (lua_State *L, const TValue *p1, const TValue *p2) { lua_Integer temp; if (!luaV_tointegerns(p1, &temp, LUA_FLOORN2I)) p2 = p1; /* report the *first* operand if it cannot convert */ luaG_runerror(L, "number%s has no integer representation", varinfo(L, p2)); }
Judging with a rounding mode makes
1.5look convertible, so1.5 & 3reported the right operand:number (local 'b') has no integer representation, where the reference reports the left,(local 'a'). Ten lines of the matrix were wrong. Hand-written cases only ever usedinf/nan, where both modes agree — which is why this survived every earlier gate.
Both matrices are now permanent tests:
RuntimeTest.operatorTypeErrorsMatchReference and
RuntimeTest.metamethodDispatchMatchesReference.
lstrlib.c: str_format is one of the densest functions in the source, so the next
probe swept it: 22 verbs × 10 flag combinations × 5 widths × 9 precisions × 22
value types — 20777 lines. It found two things.
-
The order of the two checks.
str_formatdoes not validate the spec first. Ford i u o x Xit callsluaL_checkintegerbeforecheckformat, and forf e E g Git callsluaL_checknumberfirst:case 'd': case 'i': flags = L_FMTFLAGSI; goto intcase; ... intcase: { lua_Integer n = luaL_checkinteger(L, arg); checkformat(L, form, flags, 1);
(
c,aandsare the other way round.) Validating the spec first meant("%.100d"):format("x")reportedinvalid conversion specificationwhere the reference reportsnumber expected, got string— 6620 lines wrong. -
A precision turns off the
0flag for integer verbs. C99: "Ford,i,o,u,x, andXconversions, if a precision is specified, the0flag is ignored." So("%05.3d"):format(42)is042— the precision pads to042, then the width pads with spaces — not00042. 220 lines. Float verbs have no such rule (%08.3fstill zero-pads), so the fix is confined toemitInt.
RuntimeTest.formatSpecifierMatrixMatchesReference pins both; reverting either
one alone turns it red.
The next sweep was os.date specifiers, os.time argument checking,
utf8.* boundaries, and tonumber across bases 2–36 (854 lines). It found a
familiar shape of bug: luaL_checkinteger had two implementations, and the
older one in BaseLib was missing two things — the function-name prefix
(bad argument #2 (...) instead of bad argument #2 to 'tonumber' (...)) and
the "no integer representation" case, reporting number expected, got number
for 1.5. Four call sites were affected: the base of tonumber, the index of
select, the level of error, and the index of the ipairs iterator. It now
delegates to LuaAux.checkInteger.
That sweep also turned up a second instance of "not our bug, libc disagreeing":
os.date("!%z", t). C's strftime reads the process-global timezone,
not the tm Lua hands it, and with the ! (UTC) flag the two libc's answer
differently — macOS prints the local offset +0800, glibc prints +0000. The
test therefore only exercises %z/%Z without !, where both sides run on
the same machine and the answer is well-defined.
string.pack/unpack/packsize also got a full sweep — every format verb
(including i/I widths 1–16, c/s counts, X and !n alignment, all four
byte orders) × values, plus invalid formats and out-of-range offsets, 706 lines.
It was clean; it is now a test so that "already agrees" stays agreed.
quotefloat formats through C's %a:
int nb = lua_number2strx(L, buff, MAX_ITEM, "%" LUA_NUMBER_FRMLEN "a", n);C99 fixes one hexadecimal digit to the left of the point and says it "is 0 only
if the argument is denormalized" — which leaves how a subnormal is written
unspecified. glibc and Apple libc pick opposite answers:
| value | glibc (CI: ubuntu) | Apple libc / musl |
|---|---|---|
5e-324 |
0x0.0000000000001p-1022 |
0x1p-1074 |
3 * 5e-324 |
0x0.0000000000003p-1022 |
0x1.8p-1073 |
This port matches glibc, because the gate that matters — CI — runs on
ubuntu-latest. StringLibTest.subnormalQuoteFollowsGlibc pins it with a plain
assertion outside the differential table, so that nobody "fixes" it by matching
a macOS reference binary and breaks Linux. (Double.toHexString already produces
the glibc form, so there is nothing to implement.)
The table sweep — insert / remove / move / concat / unpack / pack /
sort × positions × sizes × offsets × value types, including same-table
overlapping move and cross-table move, 6277 lines — produced 268 diff
lines, and every one of them had an unsettling shape: the table's contents were
identical, only #t differed.
< MV2-dump #0 -2=nil,-1=nil,0=nil,1=nil,2=1,3=nil,4=nil,...
> MV2-dump #2 -2=nil,-1=nil,0=nil,1=nil,2=1,3=nil,4=nil,...
The cause is a missing clause. C decides the copy direction like this:
if (t > e || t <= f || (tt != 1 && !lua_compare(L, 1, tt, LUA_OPEQ))) {
for (i = 0; i < n; i++) { ... } /* forward */
} else {
for (i = n - 1; i >= 0; i--) { ... } /* backward */
}This port had only the first two clauses. The third one is not about positions at
all: when the source and destination are different tables, the copy is always
forward, regardless of how t and f relate. Dropping it sends cases that
should go forward down the backward path.
Order does not change what ends up in the table — but it changes the layout,
which is what #t reads. table.move({1}, -2, 1, -1, {1}) leaves the destination
with only [2] = 1 either way; going forward clears [1] first and leaves
alimit at 1, so luaH_getn's first case ("array[alimit-1] is nil ⇒ binary
search inside the array part only") fires and the answer is 0. Going backward
sets [2] first, pushing alimit to 2, that case no longer applies, and the
answer becomes 2. Hence the confusing symptom: the same sequence of writes
agrees when driven through rawset and disagrees when driven through
table.move, because only the internal layout differs.
Two lessons went into the tests:
- Comparing table contents is not enough —
#thas to be compared too, or layout bugs are invisible. - The sweep is now three permanent tests:
RuntimeTest.tableLibraryMatrixMatchesReference(6277 lines, the one that found it),RuntimeTest.patternMatchingMatrixMatchesReference(24819 lines of pattern matching × subject ×init× replacement form — clean, pinned so that "already agrees" keeps agreeing), andRuntimeTest.tableLengthAcrossInsertionHistoriesMatchesReference(3736 lines that make insertion history itself the sweep dimension: every subset of1..n× three insertion orders, fill-then-punch,move-built layout,insert/removealternation, negative/zero keys, bisection up to 40, tail truncation — also clean).
Reverting the one clause turns the first of them red.
Nothing in the language or the ten standard libraries, and every upstream file
whose output is deterministic now matches byte for byte. The remaining work is
verification breadth: more differential scripts and a wider fuzz corpus. The only
upstream files still off the list are all.lua / main.lua (the driver: it needs
the T module built from ltests.c and writes time.txt), attrib.lua /
files.lua / big.lua (they need the C modules in libs/ and the globals
all.lua sets up — the reference binary fails on them standalone too),
heavy.lua (minutes), and bwcoercion.lua (no output standalone).
This is a breaking rewrite. The old fakescript syntax is gone entirely:
package, include, struct, const, ->-style for, array(), map(),
fake coroutines, :=, the Java binding annotations, and the profiler.
Existing fakescript scripts must be rewritten as Lua — which is the point: after this change, what runs is Lua 5.4, and scripts are portable to and from any other Lua 5.4 implementation. Users who still need the old language should pin to the last commit before the rewrite.
./mvnw clean test # requires JDK 21+With no lua5.4 binary on the machine, the differential tests skip themselves
(a single assumeTrue guard in Diff) and the coverage gate is skipped with
them — the differential suites are most of the line coverage, so a gate that
runs while they are all skipped would only ever be red for the wrong reason.
Set LUA54_BIN, or build the reference into target/lua54/src/lua, to get the
real gate:
LUA54_BIN=/path/to/lua5.4 ./mvnw clean testCI builds the reference from source and exports LUA54_BIN and FKDUMP_BIN, so
this only affects local builds without a reference binary.
MIT