I want to bring this issue to your attention: LuaJIT/LuaJIT#1510
I have just created it in the LuaJIT upstream, but it was actually discovered and patched in luajit2.
pairs() silently drops an array element after ITERN→ITERC despecialization (JIT)
Note
This issue was discovered while developing an OpenRESTY application, but the root cause is in LuaJIT.
A patch is provided below, but it would really need a LuaJIT expert to validate this is the correct fix (and doesn't have side effects I'm not aware of).
With the patch applied my testsetup consistently works as intended.
The investigation of this issue was heavily carried by Claude Fable and I must admit that the details go above my head.
I have carefully guided Claude in the process though, this is not just an AI generated slop report.
The explanation below is written by Claude and reviewed by myself, but again, I am not enough of an expert to assure everything Claude wrote is 100% correct.
The issue is sporadic in my test setup but once it occurs it deterministically skips elements in the pairs() of an array.
I am very happy to run additional tests or provide additional tracings if needed.
I tried to reproduce this issue in plain LuaJIT but could not hit the correct series of events that lead to the specific recording.
Also reproducing in plain OpenRESTY without my application specific code failed to hit the exact circumstances that triggers the issue.
Summary
A for k, v in pairs(t) loop over a table with an array part can silently skip
exactly one array element — one fewer iteration, no error raised — when both:
- the loop's
BC_ITERN has despecialized to a generic next call at runtime, and
- that
next call is JIT-compiled (recorded via recff_next).
The compiled trace advances the traversal control index one step too far, so one
array slot is never visited. The traversal completes normally and returns a
well-formed but incomplete result — silent data loss for anything that
materializes the sequence (serializers, table copies, etc.).
Reproduced on LuaJIT 2.1-20250117 and the current v2.1-agentzh tip
(x86_64, GC64, -DLUAJIT_NUMMODE=2 -DLUAJIT_ENABLE_LUA52COMPAT). The interpreter
is unaffected; only the compiled next path is wrong.
The fix
recff_next must treat a keyindex control (TREF_KEYINDEX) as an
already-computed next index instead of routing it through
IRCALL_lj_tab_keyindex:
--- a/src/lj_ffrecord.c
+++ b/src/lj_ffrecord.c
@@ recff_next @@
if (tref_isnil(J->base[1])) { /* Shortcut for start of traversal. */
ix.key = lj_ir_kint(J, 0);
keyv = niltvg(J2G(J));
+ } else if ((J->base[1] & TREF_KEYINDEX)) {
+ /* Despecialized ITERN control: already holds the next index, so use it
+ ** directly instead of advancing it again via lj_tab_keyindex. Matches the
+ ** LJ_KEYINDEX shortcut in the interpreter's lj_tab_keyindex.
+ */
+ ix.key = J->base[1] & ~TREF_KEYINDEX;
+ keyv = &rd->argv[1];
} else {
TRef tmp = recff_tmpref(J, J->base[1], IRTMPREF_IN1);
ix.key = lj_ir_call(J, IRCALL_lj_tab_keyindex, tab, tmp);
keyv = &rd->argv[1];
}
This mirrors the LJ_KEYINDEX fast path already present in lj_tab_keyindex
(interpreter) and recff_next's own nil-key shortcut, both of which produce the
start index directly without a previous-key→successor conversion. keyv is left
pointing at &rd->argv[1] so the concrete record-time computation is unchanged.
Details
for k, v in pairs(t) records and executes as BC_ITERN, whose control slot
holds a keyindex: an integer array index tagged LJ_KEYINDEX, semantically the
next index to fetch. When the loop instance meets a shape the ITERN fast path
cannot sustain, the interpreter despecializes the bytecode ITERN → ITERC;
ITERC invokes the next fastfunc, passing the current keyindex control as the
key argument.
On the JIT side that next(t, control) is recorded by recff_next. For a
non-nil key it emits ix.key = lj_ir_call(IRCALL_lj_tab_keyindex, tab, tmpref(key)) and feeds the result to IRCALL_lj_vm_next. The key argument is
materialized into a temporary TValue via IR_TMPREF / asm_tvptr, which stamps
the itype from the value's IR type — IRT_INT — and therefore drops the
LJ_KEYINDEX marker. At runtime lj_tab_keyindex then sees an ordinary integer
key k < asize and returns its successor k + 1, instead of taking its
if (key->u32.hi == LJ_KEYINDEX) return key->u32.lo; shortcut. The control is
advanced twice — once (correctly) by the prior lj_vm_next, then again by the
spurious lj_tab_keyindex — so the next lj_vm_next is called with an index one
past the intended slot and skips it.
The bug is invisible on the recording pass: recff_next also computes
ix.keyv.u32.lo concretely from &rd->argv[1], i.e. the actual runtime control
TValue, which does carry LJ_KEYINDEX, so the snapshot and first execution
are correct. Only later executions of the compiled trace — which reconstruct the
argument from the tagless IRT_INT value — exhibit the skip. The emitted mcode is
deterministic, so once a worker holds such a trace it drops the element on every
matching pairs() traversal until the trace is flushed.
Which array index is skipped depends on the iteration at which the next side
trace takes over: the traversal visits …, i-1, i+1, …, so consumers observe
either a missing trailing element or an interior hole (the dropped element
re-encoded as nil), depending on how they read the sequence.
Root cause
recff_next does not special-case a keyindex control. The lj_tab_keyindex
shortcut for a despecialized-in-flight ITERN control
(if (key->u32.hi == LJ_KEYINDEX) return key->u32.lo;), added with the 2021
table-traversal refactor (c6f5ef64), only fires when the argument reaches the C
function still carrying LJ_KEYINDEX. That holds in the interpreter, where the
control lives in a real stack slot, but not in compiled code, where the argument
is rebuilt from an IRT_INT IR value whose type materialization has no notion of
LJ_KEYINDEX. The recorder therefore emits a previous-key→successor conversion
for a value that is already the next index, double-advancing it. recff_next is
the only recorder that can receive a keyindex control (TREF_KEYINDEX is present
on J->base[1]), and short-circuiting it there — rather than teaching the shared
IR_TMPREF materialization about LJ_KEYINDEX — is the correct fix. The
interpreter-side shortcut was added without the corresponding recorder handling.
Background
Discovered in a production OpenResty application whose data layer uses the stock
resty.mongol BSON encoder. That encoder classifies every table with a single
generic for k, v in pairs(ob) loop, driving the same loop instance over both
array-part tables and string-keyed maps — precisely the mix that despecializes
ITERN and then grows a compiled next side trace. Under the application's mixed,
coroutine-yield-fragmented request traffic the classification loop reliably enters
this state, after which array documents are persisted to MongoDB with an element
silently dropped. The occurrence is intermittent per worker process (it depends on
trace-compilation order) but fully deterministic once a worker's trace cache holds
the affected trace; it does not reproduce in a straight-line script that only ever
traverses arrays, since the general-next path is never compiled.
I want to bring this issue to your attention: LuaJIT/LuaJIT#1510
I have just created it in the LuaJIT upstream, but it was actually discovered and patched in luajit2.
pairs()silently drops an array element after ITERN→ITERC despecialization (JIT)Note
This issue was discovered while developing an OpenRESTY application, but the root cause is in LuaJIT.
A patch is provided below, but it would really need a LuaJIT expert to validate this is the correct fix (and doesn't have side effects I'm not aware of).
With the patch applied my testsetup consistently works as intended.
The investigation of this issue was heavily carried by Claude Fable and I must admit that the details go above my head.
I have carefully guided Claude in the process though, this is not just an AI generated slop report.
The explanation below is written by Claude and reviewed by myself, but again, I am not enough of an expert to assure everything Claude wrote is 100% correct.
The issue is sporadic in my test setup but once it occurs it deterministically skips elements in the pairs() of an array.
I am very happy to run additional tests or provide additional tracings if needed.
I tried to reproduce this issue in plain LuaJIT but could not hit the correct series of events that lead to the specific recording.
Also reproducing in plain OpenRESTY without my application specific code failed to hit the exact circumstances that triggers the issue.
Summary
A
for k, v in pairs(t)loop over a table with an array part can silently skipexactly one array element — one fewer iteration, no error raised — when both:
BC_ITERNhas despecialized to a genericnextcall at runtime, andnextcall is JIT-compiled (recorded viarecff_next).The compiled trace advances the traversal control index one step too far, so one
array slot is never visited. The traversal completes normally and returns a
well-formed but incomplete result — silent data loss for anything that
materializes the sequence (serializers,
tablecopies, etc.).Reproduced on LuaJIT 2.1-20250117 and the current
v2.1-agentzhtip(x86_64, GC64,
-DLUAJIT_NUMMODE=2 -DLUAJIT_ENABLE_LUA52COMPAT). The interpreteris unaffected; only the compiled
nextpath is wrong.The fix
recff_nextmust treat a keyindex control (TREF_KEYINDEX) as analready-computed next index instead of routing it through
IRCALL_lj_tab_keyindex:This mirrors the
LJ_KEYINDEXfast path already present inlj_tab_keyindex(interpreter) and
recff_next's own nil-key shortcut, both of which produce thestart index directly without a previous-key→successor conversion.
keyvis leftpointing at
&rd->argv[1]so the concrete record-time computation is unchanged.Details
for k, v in pairs(t)records and executes asBC_ITERN, whose control slotholds a keyindex: an integer array index tagged
LJ_KEYINDEX, semantically thenext index to fetch. When the loop instance meets a shape the ITERN fast path
cannot sustain, the interpreter despecializes the bytecode
ITERN → ITERC;ITERCinvokes thenextfastfunc, passing the current keyindex control as thekey argument.
On the JIT side that
next(t, control)is recorded byrecff_next. For anon-nil key it emits
ix.key = lj_ir_call(IRCALL_lj_tab_keyindex, tab, tmpref(key))and feeds the result toIRCALL_lj_vm_next. The key argument ismaterialized into a temporary
TValueviaIR_TMPREF/asm_tvptr, which stampsthe itype from the value's IR type —
IRT_INT— and therefore drops theLJ_KEYINDEXmarker. At runtimelj_tab_keyindexthen sees an ordinary integerkey
k < asizeand returns its successork + 1, instead of taking itsif (key->u32.hi == LJ_KEYINDEX) return key->u32.lo;shortcut. The control isadvanced twice — once (correctly) by the prior
lj_vm_next, then again by thespurious
lj_tab_keyindex— so the nextlj_vm_nextis called with an index onepast the intended slot and skips it.
The bug is invisible on the recording pass:
recff_nextalso computesix.keyv.u32.loconcretely from&rd->argv[1], i.e. the actual runtime controlTValue, which does carryLJ_KEYINDEX, so the snapshot and first executionare correct. Only later executions of the compiled trace — which reconstruct the
argument from the tagless
IRT_INTvalue — exhibit the skip. The emitted mcode isdeterministic, so once a worker holds such a trace it drops the element on every
matching
pairs()traversal until the trace is flushed.Which array index is skipped depends on the iteration at which the
nextsidetrace takes over: the traversal visits
…, i-1, i+1, …, so consumers observeeither a missing trailing element or an interior hole (the dropped element
re-encoded as
nil), depending on how they read the sequence.Root cause
recff_nextdoes not special-case a keyindex control. Thelj_tab_keyindexshortcut for a despecialized-in-flight ITERN control
(
if (key->u32.hi == LJ_KEYINDEX) return key->u32.lo;), added with the 2021table-traversal refactor (
c6f5ef64), only fires when the argument reaches the Cfunction still carrying
LJ_KEYINDEX. That holds in the interpreter, where thecontrol lives in a real stack slot, but not in compiled code, where the argument
is rebuilt from an
IRT_INTIR value whose type materialization has no notion ofLJ_KEYINDEX. The recorder therefore emits a previous-key→successor conversionfor a value that is already the next index, double-advancing it.
recff_nextisthe only recorder that can receive a keyindex control (
TREF_KEYINDEXis presenton
J->base[1]), and short-circuiting it there — rather than teaching the sharedIR_TMPREFmaterialization aboutLJ_KEYINDEX— is the correct fix. Theinterpreter-side shortcut was added without the corresponding recorder handling.
Background
Discovered in a production OpenResty application whose data layer uses the stock
resty.mongolBSON encoder. That encoder classifies every table with a singlegeneric
for k, v in pairs(ob)loop, driving the same loop instance over botharray-part tables and string-keyed maps — precisely the mix that despecializes
ITERN and then grows a compiled
nextside trace. Under the application's mixed,coroutine-yield-fragmented request traffic the classification loop reliably enters
this state, after which array documents are persisted to MongoDB with an element
silently dropped. The occurrence is intermittent per worker process (it depends on
trace-compilation order) but fully deterministic once a worker's trace cache holds
the affected trace; it does not reproduce in a straight-line script that only ever
traverses arrays, since the general-
nextpath is never compiled.