LuaJIT Upstream issue: pairs() silently drops an array element after ITERN→ITERC despecialization
Nobody has claimed this yet.
- Dominant language
- C
- Stars
- 1.4k
- Forks
- 252
- Avg merge
- 7d 11h
- Merged PRs (30d)
- 3
Description
I want to bring this issue to your attention: https://github.com/LuaJIT/LuaJIT/issues/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_ITERNhas despecialized to a genericnextcall at runtime, and - that
nextcall 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, 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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read src/lj_ffrecord.c at recff_next and compare it with the LJ_KEYINDEX shortcut in lj_tab_keyindex. Begin by examining the supplied despecialization and JIT scenario, using additional tracing or tests if needed. Done means validating that mixed-table pairs() traversals no longer skip an array element without introducing regressions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, lua
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100