Skip to content

LuaJIT: statically-linked GC64 tarantool support (amd64 + arm64) - #49

Closed
dalehamel wants to merge 21 commits into
dale/luajit-unwinder-rebasefrom
dale/luajit-tarantool-fixes
Closed

dalehamel wants to merge 21 commits into
dale/luajit-unwinder-rebasefrom
dale/luajit-tarantool-fixes

Conversation

@dalehamel

Copy link
Copy Markdown
Member

LuaJIT unwinding for statically-linked GC64 tarantool (amd64 + arm64)

Stacked on #48 (the clean parca open-telemetry#1236 LuaJIT interpreter merged onto the Ruby-JIT base #46). This branch holds the Shopify/tarantool-specific changes needed to make the upstream LuaJIT unwinder work on statically-linked, GC64 tarantool on both amd64 and arm64 — without regressing the dynamically-linked OpenResty/nginx case parca targets.

Validated end-to-end on a real production amd64 control-plane node (full native C stack fiber_loop → … → lua_pcall resolving into real control-plane Lua frames like box:space:ffs_node_lifecycle:get, slot_counts_for_target), and on arm64.

Why upstream doesn't cover this

parca's x86 LuaJIT support is validated against OpenResty's libluajit-5.1.so (shared library, GC64-on, dynamic symbols). tarantool is the opposite on every axis: LuaJIT is statically linked into the tarantool binary, GC64 is off by default on x86 (we build it on), symbols are hidden in .symtab, and Lua runs inside fibers (mmap'd stacks). Each mismatch needed a fix.

What's here

  • Detect statically-linked LuaJIT in the host binary + make the host-executable list configurable (drop hardcoded tarantool).
  • x86 offset extractor robustness: sameReg EBP/ESP/EDI→64-bit; look up lj_vm_asm_begin via .symtab (scanSymbols), not just dynamic symbols; anchor the interpreter range to the lj_vm_asm_begin symbol (the gap heuristic matches an unrelated function in a big static binary); sign-extend disp32 (x86asm reports it unsigned).
  • mem_L / jit_base: tarantool's global_State inserts a mem_L field between cur_L and jit_base, so the eBPF can't assume jit_base is adjacent to cur_L. Extract g2jitbase (x86: from lj_vm_exit_handler; arm64: from the same store, x22-relative).
  • Native handback cframe sizes from .eh_frame: the hardcoded LUAJIT_CFRAME_SPACE is wrong for tarantool. Derive the interp cframe from the interp region's own stack delta (x86 SP+96, arm64 FP+16), and the JIT cframe as interp + 16. Also hand back to native at end-of-stack for the interpreter case (not just JIT). This fixes ERROR_4012/4005 and recovers the native C stack below the VM gate.
  • Tests: lj_vm_asm_begin anchor (hermetic + real OpenResty binaries, both arches), OpenResty g2jitbase == cur_L+8 regression guard.

Non-regression

OpenResty/luajit2 has no mem_L and its own cframe sizes; all the new values are extracted per-binary (or fall back to the prior constants), so the dynamically-linked path is unchanged. A coredump regression matrix (arch × static/dynamic × tarantool/openresty) is planned as follow-up.

@dalehamel
dalehamel force-pushed the dale/luajit-unwinder-rebase branch from 2550eaa to 32dd7c6 Compare June 30, 2026 17:02
@dalehamel
dalehamel force-pushed the dale/luajit-tarantool-fixes branch from 6eacc37 to 795a76a Compare June 30, 2026 17:02
@dalehamel
dalehamel force-pushed the dale/luajit-unwinder-rebase branch from 32dd7c6 to f17e649 Compare June 30, 2026 17:32
@dalehamel
dalehamel force-pushed the dale/luajit-tarantool-fixes branch from 795a76a to c381199 Compare June 30, 2026 17:33
@dalehamel
dalehamel force-pushed the dale/luajit-unwinder-rebase branch from f17e649 to 81b1e8f Compare June 30, 2026 17:41
dalehamel added 18 commits June 30, 2026 13:41
The Loader only matched libluajit-5.1.so*/luajit*/nginx/openresty. tarantool
(shopkv) statically links LuaJIT into the main 'tarantool' executable, so no
separate libluajit mapping exists and the unwinder never engaged. Add
'tarantool' to the allowlist so the LuaJIT offset extraction runs against the
tarantool binary.

NOTE: offset extraction was derived from openresty/luajit2; tarantool ships its
own LuaJIT fork, so extraction may still need tarantool-specific offsets — this
makes the unwinder attempt tarantool and surfaces concrete extraction signal.
…antool)

Generalize the executable-detection allowlist: instead of hardcoding which
binaries statically link LuaJIT, add a luajit.Config.Executables list so
embedders (tarantool, custom static binaries) opt in via the per-interpreter
interpreter.Config. embedsLuaJIT() = built-in set (libluajit-5.1.so*/luajit*/
nginx/openresty) + configured Executables. Supersedes the hardcoded 'tarantool'
match; this is the patch proposed upstream on open-telemetry#1236.
… from non-GC64 lua_close)

extractor_x86.findOffsetsFromLuaClose derives glref/curL by matching the
register that holds global_State across the lua_close disassembly. sameReg only
mapped EAX/ECX/EDX/EBX/ESI to their 64-bit forms, omitting EBP/ESP/EDI.

Tarantool's x86 (non-GC64) LuaJIT loads G with a 32-bit `movl 0x8(%rdi),%ebp`
(greg=EBP) and then stores cur_L via `movl $0x0,0x188(%rbp)` (base RBP).
sameReg(RBP,EBP) returned false, so the cur_L store never matched and extraction
failed with 'find offsets from lua_close failed' — no Lua unwinding on x86
tarantool (arm64 worked). Complete the 32->64-bit GPR mapping via reg64().
…resent

extractInterpreterBounds scans stack deltas for the first large gap matching the
VM unwind pattern. On x86 tarantool that matched an unrelated function (0x164469)
instead of the real lj_vm_asm_begin (0x261ca0), so extractOffsets failed its
start-address sanity check ('unexpected start address') and LuaJIT unwinding was
skipped (only native lj_* frames + ERROR_4012 from unmapped JIT mcode).

When the binary is unstripped (tarantool ships lj_vm_asm_begin), look the symbol
up via ef.LookupSymbol and return the delta gap that starts exactly there;
fall back to the heuristic for stripped binaries (asmBegin==0). Tests pass 0.
…m_asm_begin

The VM asm FDE starts at lj_vm_asm_begin but the preceding function's unwind info
extends to a few bytes below it, so the delta interval starts just under the
symbol (exact-address match missed it). Match the large delta interval that
contains the symbol and start the range exactly at lj_vm_asm_begin.
….LookupSymbol

ef.LookupSymbol only resolves dynamic symbols; lj_vm_asm_begin is a hidden
.symtab symbol, so the lookup returned ErrSymbolNotFound and asmBegin stayed 0,
skipping the symbol-anchored interp-range logic entirely. Use scanSymbols (reads
.symtab via VisitSymbols), matching how extractOffsets resolves the symbol.
…egression

- Hermetic TestExtractInterpreterBoundsAnchor: proves the anchor picks the VM
  asm over a decoy SP/param gap (x86 bug) and is byte-identical to the heuristic
  on the arm64 frame-pointer layout; covers start-below-symbol + fallback.
- Strengthen the OpenResty integration test to run the anchor on real shared-lib
  builds (amd64+arm64) and assert it yields identical offsets to the heuristic.
…ARF)

Tarantool's LuaJIT inserts a mem_L field in global_State between cur_L and
jit_base, so the eBPF assumption that jit_base is adjacent to cur_L read mem_L
instead -> garbage frame base for JIT-trace samples -> unresolved <lua> frames.

Extract jit_base's offset (g2jitbase) from lj_vm_exit_handler, which clears
G->jit_base via the DISPATCH register on trace exit (g2jitbase = g2dispatch +
disp). Cheap targeted disassembly, no DWARF. Falls back to cur_L+8 on stripped
binaries, correct for OpenResty/luajit2 (no mem_L). arm64 extraction is a TODO
(falls back; no regression to the arm64 interpreter path).

- support/ebpf: LuaJITProcInfo.g2jitbase; read jit_base from G+g2jitbase.
- offsets.go: findG2JitBaseOffset (+ lj_vm_exit_handler symbol); extractor iface.
- extractor_x86.go: findG2JitBaseFromExitHandler (movq $0,disp(%r14) -> g2dispatch+disp).
- offsets_test.go: assert openresty g2jitbase == cur_L+8 (regression guard, both arches).
…t unsigned)

x86asm.Decode reports a negative disp32 as its unsigned 32-bit value in the
int64 Disp field (0xFFFFF1E0, not -3616), so the g2dispatch+disp candidate
overflowed the validation range and the extractor fell back to cur_L+8 (mem_L).
Sign-extend the low 32 bits. Verified against the tarantool binary: g2jitbase
now extracts 0x1f0 (jit_base) instead of 0x1e8 (mem_L).
arm64 tarantool also has the mem_L field, so jit_base is at cur_L+0x10 not +8.
On arm64 the VM holds G directly in a register (x22), so the trace-exit clear
'str xzr, [x22, #0x1f0]' encodes jit_base's offset from G directly (g2dispatch
unused, unlike x86). Mirrors the setgcrefnull(g->cur_L) matcher in lua_close.
Verified against the real arm64 binary: g2jitbase=0x1f0.
…R_4012/4005)

After Lua frames resolve, the unwinder hands back to native by stepping over the
VM gate's C frame using a hardcoded LUAJIT_CFRAME_SPACE (x86 80, arm64 208). Both
are wrong for tarantool: the interp region's own stack delta says x86 CFA=sp+96
and arm64 CFA=fp+16 (frame-pointer based). The mismatch lands the resume PC in an
unmapped region -> every tarantool stack carried ERROR_4012 (x86, no_pid_page_mapping)
/ ERROR_4005 (arm64, stack_delta_invalid), losing the native frames below the VM.

Extract the interp CFA offset + base register from the interpreter region's stack
delta (info.Deltas covering luaInterp.Start) and use them in unwind_native_frame:
SP-based (sp+=param) on x86, FP-based (sp=fp+param) on arm64. Falls back to
LUAJIT_CFRAME_SPACE when unavailable. New LuaJITProcInfo.cframe_size_interp/interp_fp.
… ERROR_4012)

At the bottom of the Lua stack (diff<=2) the code only stepped over the C frame
(unwind_native_frame) for is_jit; for the interpreter it set next_unwinder=NATIVE
with state->pc still in the luajit-mapped interp region, so the native unwinder
re-entered luajit/failed -> ERROR_4012 and the C stack below the VM was dropped.
arm64 happened to take the FRAME_CP path (which pokes); amd64 (tarantool, where
the bottom Lua frame meets C here) took this path. Now poke for both.
…r handback)

The JIT native handback resumed 16 bytes short -> into the fiber stack/heap
instead of lua_pcall's return -> ERROR_4012, no native C stack. Root cause:
cframeSizeJIT = cframeSize(80) + 16, but 80 is OpenResty's VM gate cframe; tarantool's
lj_vm_pcall gate cframe is 96 (4 pushes + sub 56 + ret), which we already extract as
cframeSizeInterp. Use that base on SP-based (x86) builds: cframeSizeJIT = interp + 16 = 112.
Verified against the live fiber stack: lua_pcall's return address sits at new_sp+8.
arm64 (FP-based) keeps the arch constant; its JIT path isn't the hot path there.
@dalehamel
dalehamel force-pushed the dale/luajit-tarantool-fixes branch from c381199 to 7916b62 Compare June 30, 2026 17:42
…d LuaJIT hosts)

Lets coredump test cases probe binaries that statically link LuaJIT (e.g.
tarantool) during extraction, and persists the config into the test case so
the harness probes the same hosts on replay.
Resolves churn->hot_loop->main Lua frames over the native C stack on
statically-linked GC64 tarantool. Skipped until core/modules are uploaded to
the module store. Documents the jit.off capture, the -luajit-executables flag,
the expected ?+0x0 interpreter-boundary frame, and the arm64 fiber-trampoline
abort.
Resolves fib->hot_loop Lua frames over the full native C stack on amd64
GC64 tarantool; unwinds cleanly to the fiber entry (no fiber-trampoline
abort, unlike arm64). Skipped pending module-store upload. README updated
to cover both arches.
@github-actions

Copy link
Copy Markdown

This PR was marked stale due to lack of activity. It will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Jul 29, 2026
@github-actions

Copy link
Copy Markdown

Closed as inactive. Feel free to reopen if this PR is still being worked on.

@github-actions github-actions Bot closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant