Skip to content

Commit 7982fae

Browse files
committed
ffi: exercise authentic generic scalar CALLXS
1 parent b248799 commit 7982fae

15 files changed

Lines changed: 1023 additions & 48 deletions
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Authentic generic scalar CALLXS checkpoint (2026-07-18)
2+
3+
## Status and scope
4+
5+
This checkpoint executes the real generic x64 `IR_CALLXS` seam between the
6+
XSAVE-consuming exact-frame entry/leave helpers. Activation is deliberately
7+
test-only under `LJ_FFI_CALLXS_TEST_ACTIVATE`; the default recorder still raises
8+
`LJ_TRERR_BLACKL`. No `plan/` file is changed.
9+
10+
The path is declaration-independent. It adds no signature enum, C declaration
11+
matcher, wrapper catalogue, or per-shape dispatcher. CType-driven argument
12+
conversion and the existing x64 ABI classifier continue to build one generic
13+
`CARG` tree and lower one `CALLXS`.
14+
15+
## Generated lifecycle
16+
17+
For the currently admitted nonallocating scalar result-handoff classes,
18+
recording builds every CType argument conversion before the native boundary,
19+
then emits:
20+
21+
```text
22+
XSAVE
23+
CALLS lj_ffi_native_trace_enter(L, exact_trace, raw_function)
24+
guard enter != 0 -- pre-call/replaying snapshot
25+
CALLXS generic_CARG_tree
26+
pure result normalization
27+
caller-state snapshot
28+
CALLS lj_ffi_native_trace_leave(L) -- first C call after the foreign call
29+
guard leave == 0 -- post-call/non-replaying snapshot
30+
```
31+
32+
The admitted result normalizations are nonthrowing machine conversions and do
33+
not alter the foreign error pair. Leave remains the first C call after the
34+
foreign return. Its `CCI_T` unwind and its forced guard now share the caller
35+
snapshot because that snapshot's IR reference names leave itself. The enter and
36+
leave IRCALL descriptors intentionally do not claim preserved FP registers;
37+
normal register allocation must move or spill the normalized result across
38+
leave. The authentic matrix proves both GPR and XMM result survival.
39+
40+
Both native-boundary guard snapshots carry `SNAPCOUNT_DONE`, used here as an
41+
explicit never-record marker. LOOP copy-substitution preserves this marker.
42+
The pre-call rejection stays in the interpreter, while the completed-call exit
43+
must pass through central trace-exit cleanup; linking either exit to a side
44+
trace could otherwise cross or bypass native lifecycle ownership.
45+
46+
The opt-in boundary currently accepts ordinary `CALL`, `CALLM`, and `ITERC`
47+
Lua frames only. Bool, pointer, enum, i64 and u64 results stay interpreted:
48+
their current result paths branch or allocate cdata after the foreign side
49+
effect. Protected, continuation, vararg-frame and tail-return shapes also stay
50+
interpreted until each has an exact post-return snapshot contract. These are
51+
return/frame-class safety boundaries, not declaration matching.
52+
53+
## Exact trace constant on loop traces
54+
55+
The first authentic loop showed that KTRACE patching in `asm_tail_link()` was
56+
not sufficient: normal loop traces skip tail linking, so their placeholder
57+
remained null and native entry rejected every execution. That looked correct at
58+
the Lua level because the pre-call exit replayed the call in the interpreter.
59+
60+
KTRACE is now patched immediately after each assembly attempt selects the
61+
copied `J->curfinal->ir`. This covers loop and non-loop traces plus IR-growth,
62+
alignment and RENAME retries which allocate a fresh final body. The generated
63+
fixture inspects the enter CARG and proves its KGC is the exact finalized body.
64+
65+
## Caller-state post-call snapshot
66+
67+
The first forced leave exit exposed a real continuation bug. `lj_record_ret()`
68+
had already moved `J->base`, `baseslot`, `framedepth` and result slots back to
69+
the Lua caller, but `L->base/top` and `J->pc/fn/pt` still described the live
70+
fast-function retry. The snapshot therefore combined caller values with the
71+
synthetic `BC_FUNCC` PC. Its side trace started at source line zero and restore
72+
resumed `lj_BC_FUNCC` with a Lua closure where a C closure was expected,
73+
jumping through a Lua upvalue word into non-executable GC memory.
74+
75+
`ffrecord_postcall_snap()` now presents the matching Lua caller view only while
76+
the snapshot is built. It does not shift the recorder stack a second time. It
77+
validates the caller PC against the caller prototype, snapshots with caller
78+
`L->base/top` and `J->pc/fn/pt`, and then restores the still-live fast-function
79+
view. Terminal trace links and unsupported physical frame shapes fail recording
80+
instead of appending a guard to an invalid continuation.
81+
82+
For its i32 side-effect target, the C fixture mechanically verifies that every
83+
activated CALLXS is immediately followed by native leave, every leave guard
84+
owns a snapshot inside the expected Lua prototype, and the root snapshot's
85+
frame/top encoding matches that prototype. It then forces a handshake-epoch
86+
change while the final frame sequence is odd. The real guard restores the
87+
caller, releases the retained pin once, preserves the exact foreign side-effect
88+
count, and leaves the root safely re-enterable.
89+
90+
## One-shot XSAVE ownership
91+
92+
Every native-entry attempt consumes all three XSAVE staging words. Earlier
93+
rejected entries left them intact for the pre-call snapshot exit, but a hot
94+
exit can link directly to side code and bypass central trace-exit cleanup.
95+
Authentic occupied-depth injection exposed the stale raw
96+
`ffi_xsave_root/baseslot/nslots` geometry after interpreter fallback.
97+
98+
`lj_ffi_native_trace_enter()` now performs the complementary owner clear on
99+
every valid-carrier return, including capacity, occupied-depth, malformed
100+
geometry, trace-pin and publication rejection. This keeps dormant XSAVE-only
101+
instrumentation independent of unrelated trace exits. The injected rejection
102+
proves that CALLXS and generated leave do not run, the interpreter performs each
103+
foreign side effect exactly once, and all staging is zero afterward.
104+
105+
## Deterministic evidence
106+
107+
`m7_ffi_callxs_authentic` builds the runtime with the explicit activation macro
108+
and runs two fixtures:
109+
110+
- `t-ffi-callxs-authentic.lua` covers void, signed and unsigned 8/16/32-bit
111+
scalar returns, float, double, mixed GPR/XMM/pointer/u64 arguments, C
112+
varargs, and generated-call `errno` preservation. It requires real XSAVE and
113+
CALLXS IR, while proving bool, pointer, enum, i64 and u64 results remain
114+
interpreted with their exact values intact.
115+
- `t-ffi-callxs-postcall.c` proves exact finalized KTRACE identity, immediate
116+
leave ordering, caller snapshot PC/frame geometry, permanent no-side-trace
117+
markers, repeated forced POSTCALL cleanup under `hotexit=1`, exact side
118+
effects, a deterministic fresh-STOPREQ injection between generated native
119+
leave and its throw decision from the same caller snapshot, re-entry,
120+
rejected-entry fallback, zero native depth, zero frame depth, zero trace pins,
121+
restored callback/function mirrors and cleared XSAVE staging.
122+
123+
Focused validation passed:
124+
125+
- `m7_ffi_callxs_authentic`;
126+
- default-gated `m7_ffi_ccall_native` and `m7_ffi_native_frames`;
127+
- `m6_jit_xsave`;
128+
- `m5_jit_trace_publish`;
129+
- Clang 19 ASan with fail-fast and leak checking disabled for both authentic
130+
fixtures;
131+
- Clang 19 UBSan with the repository's documented alignment, function,
132+
pointer-overflow and shift exclusions;
133+
- default, `LUAJIT_DISABLE_JIT`, and `LUAJIT_DISABLE_FFI` builds.
134+
135+
## Production gate remains closed
136+
137+
This checkpoint does not claim safe default generic FFI tracing. The remaining
138+
coupled gates are:
139+
140+
1. callback `ACTIVE -> SUSPENDED -> ACTIVE` publication, nested callback and
141+
error-unwind cleanup, plus nonwaiting carrier leasing;
142+
2. pre-rooted boxed result storage and nonthrowing bool/result handoff;
143+
3. exact snapshot contracts for pcall, continuation, vararg and tail/RETF
144+
return shapes;
145+
4. remote trace-flush admission for a certified pinned ACTIVE frame, followed
146+
by forced POSTCALL exit from the retired exact body;
147+
5. descriptor-driven aggregate argument and multi-register result lowering;
148+
6. Linux stress and sanitizer proof followed by Win64/Wine and macOS/Darling
149+
ABI and error-state coverage.
150+
151+
Unknown foreign functions can callback on their first invocation regardless of
152+
their declaration, so historical callback blacklisting cannot justify opening
153+
the default gate. The next lifecycle tranche is callback suspension, not a new
154+
signature allowlist.

src/lj_asm.c

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2397,12 +2397,6 @@ static void asm_tail_link(ASMState *as)
23972397
emit_addptr(as, RID_BASE, 8*(int32_t)baseslot);
23982398
checkmclim(as);
23992399

2400-
if (as->J->ktrace) { /* Patch ktrace slot with the final GCtrace pointer. */
2401-
IRIns *ir = IR(as->J->ktrace);
2402-
ir_kgc_store_rel(ir, obj2gco(as->J->curfinal));
2403-
la_store8_rel((uint8_t *)&ir->o, IR_KGC); /* 05 section 5.7.4 trace root. */
2404-
}
2405-
24062400
/* Sync the interpreter state with the on-trace state. */
24072401
checkmclim(as);
24082402
asm_stack_restore(as, snap);
@@ -2412,6 +2406,22 @@ static void asm_tail_link(ASMState *as)
24122406
asm_stack_check(as, as->topslot, NULL, as->freeset & RSET_GPR, snapno);
24132407
}
24142408

2409+
/* Patch the exact-body constant in every assembly attempt. Loop traces skip
2410+
** asm_tail_link(), and an IR-growth retry allocates a fresh curfinal copy, so
2411+
** neither tail linking nor a one-time patch is sufficient. Assembly proceeds
2412+
** backwards after this point and therefore observes the final KGC value when
2413+
** lowering native-entry arguments. */
2414+
static void asm_patch_ktrace(ASMState *as)
2415+
{
2416+
if (as->J->ktrace) {
2417+
IRIns *ir = IR(as->J->ktrace);
2418+
lj_assertA(ir->o == IR_KNUM || ir->o == IR_KNULL || ir->o == IR_KGC,
2419+
"bad exact trace constant op %d", ir->o);
2420+
ir_kgc_store_rel(ir, obj2gco(as->J->curfinal));
2421+
la_store8_rel((uint8_t *)&ir->o, IR_KGC); /* Trace-owned GC root. */
2422+
}
2423+
}
2424+
24152425
/* -- Trace setup --------------------------------------------------------- */
24162426

24172427
/* Clear reg/sp for all instructions and add register hints. */
@@ -2772,6 +2782,7 @@ void lj_asm_trace(jit_State *J, GCtrace *T)
27722782
as->mcp_prev = as->mcp;
27732783
#endif
27742784
as->ir = J->curfinal->ir; /* Use the copied IR. */
2785+
asm_patch_ktrace(as);
27752786
as->curins = J->cur.nins = as->orignins;
27762787
#ifdef LUAJIT_RANDOM_RA
27772788
as->prngstate = J2TG(J)->prng; /* Must (re)start from identical state. */

src/lj_ccall.c

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1719,12 +1719,19 @@ LJ_STATIC_ASSERT((LJ_FFI_NATIVE_LEAVE_FORCE_EXIT & 0x0000ffffu) == 0);
17191719

17201720
#if defined(LJ_XSAVE_TEST_HELPERS)
17211721
static LJFFINativeTraceFinishHook ffi_native_trace_finish_hook;
1722+
static LJFFINativeTraceLeaveHook ffi_native_trace_leave_hook;
17221723

17231724
void lj_ffi_native_trace_test_set_finish_hook(
17241725
LJFFINativeTraceFinishHook hook)
17251726
{
17261727
la_storefunc_rel(&ffi_native_trace_finish_hook, hook);
17271728
}
1729+
1730+
void lj_ffi_native_trace_test_set_leave_hook(
1731+
LJFFINativeTraceLeaveHook hook)
1732+
{
1733+
la_storefunc_rel(&ffi_native_trace_leave_hook, hook);
1734+
}
17281735
#endif
17291736

17301737
/* Validate owner-private staging entirely through integer byte geometry. This
@@ -1786,6 +1793,17 @@ static int ccall_jit_geometry(lua_State *L, TGState *tg,
17861793
return 1;
17871794
}
17881795

1796+
/* XSAVE staging is a single generated-entry attempt, not persistent carrier
1797+
** state. Consume it after successful publication or before any ordinary
1798+
** rejection returns. This avoids leaving raw stack geometry live while the
1799+
** pre-call exit resumes in the interpreter. */
1800+
static void ccall_jit_xsave_clear(TGState *tg)
1801+
{
1802+
la_storeptr_rel((void **)&tg->ffi_xsave_root, NULL);
1803+
la_store32_rel(&tg->ffi_xsave_baseslot, 0);
1804+
la_store32_rel(&tg->ffi_xsave_nslots, 0);
1805+
}
1806+
17891807
/* The executing trace's published jit_base is the independent lifetime proof
17901808
** required before the first T read. SMR then validates the exact public slot
17911809
** while native-pin admission races retirement without waiting. */
@@ -1823,8 +1841,9 @@ int lj_ffi_native_trace_enter(lua_State *L, GCtrace *T, void *func)
18231841
CCallErrorState err;
18241842
CCallJITGeometry geo;
18251843
LJFFINativeFrame frame;
1826-
global_State *g;
1827-
TGState *tg;
1844+
global_State *g = NULL;
1845+
TGState *tg = NULL;
1846+
TGState *current = NULL;
18281847
CCallbackRuntime *cb;
18291848
TraceNo traceno;
18301849
uint32_t depth;
@@ -1833,11 +1852,19 @@ int lj_ffi_native_trace_enter(lua_State *L, GCtrace *T, void *func)
18331852
/* This must remain the first operation: entry validation and SMR admission
18341853
** must not replace the errno/LastError pair observed by the foreign call. */
18351854
ccall_error_save(&err);
1836-
if (!L || !T || !func)
1855+
if (!L)
18371856
goto out;
1838-
tg = L2TG(L);
18391857
g = G(L);
1840-
if (!tg || !g)
1858+
if (!g)
1859+
goto out;
1860+
/* IR_XSAVE writes through RID_DISPATCH, which the x64 VM loads directly
1861+
** from L->tg_hint. Require that staged/dispatch carrier to be the current
1862+
** TLS carrier before reading or clearing any owner-private word. */
1863+
tg = L->tg_hint;
1864+
current = G2TG(g);
1865+
if (!tg || tg != current || lj_tg_load_cur_L(tg) != L)
1866+
goto out;
1867+
if (!T || !func)
18411868
goto out;
18421869
/* Capacity is the expected nonexceptional failure. Check it before the raw
18431870
** trace constant so a full stack always side-exits without touching it. */
@@ -1881,17 +1908,21 @@ int lj_ffi_native_trace_enter(lua_State *L, GCtrace *T, void *func)
18811908
goto out;
18821909
}
18831910
/* The exact even frame precedes every remotely acknowledged native state.
1884-
** Consume staging only after publication; every earlier failure leaves it
1885-
** intact for the pre-call side exit. */
1886-
la_storeptr_rel((void **)&tg->ffi_xsave_root, NULL);
1887-
la_store32_rel(&tg->ffi_xsave_baseslot, 0);
1888-
la_store32_rel(&tg->ffi_xsave_nslots, 0);
1911+
** Successful entry consumes staging here after publication; every ordinary
1912+
** earlier rejection consumes it at the common return. */
1913+
ccall_jit_xsave_clear(tg);
18891914
ccallback_slot_rel(cb, ~0u);
18901915
lj_tg_ffi_call_func_rel(tg, func);
18911916
ccallback_native_had_stopreq_rel(cb, had_stopreq);
18921917
lj_native_enter(tg);
18931918
ok = 1;
18941919
out:
1920+
/* A valid current-carrier route owns the staged words even when admission
1921+
** rejects before geometry, pinning or frame publication. The equality checks
1922+
** above prevent a stale hint from mutating a different carrier. */
1923+
if (!ok && tg != NULL && tg == current &&
1924+
lj_tg_load_cur_L(tg) == L)
1925+
ccall_jit_xsave_clear(tg);
18951926
ccall_error_restore(&err);
18961927
return ok;
18971928
}
@@ -1901,7 +1932,7 @@ uint32_t lj_ffi_native_trace_leave(lua_State *L)
19011932
CCallErrorState err;
19021933
LJFFINativeFrame frame;
19031934
global_State *g;
1904-
TGState *tg;
1935+
TGState *tg, *current;
19051936
CCallbackRuntime *cb;
19061937
LJFFINativeFrame *src;
19071938
GCtrace *T;
@@ -1914,9 +1945,10 @@ uint32_t lj_ffi_native_trace_leave(lua_State *L)
19141945
ccall_error_save(&err);
19151946
if (LJ_UNLIKELY(L == NULL))
19161947
abort();
1917-
tg = L2TG(L);
19181948
g = G(L);
1919-
if (LJ_UNLIKELY(tg == NULL || g == NULL ||
1949+
tg = L->tg_hint;
1950+
current = g ? G2TG(g) : NULL;
1951+
if (LJ_UNLIKELY(tg == NULL || tg != current || g == NULL ||
19201952
lj_tg_load_cur_L(tg) != L))
19211953
abort();
19221954
depth = lj_ffi_native_frame_depth_acq(tg);
@@ -1936,6 +1968,16 @@ uint32_t lj_ffi_native_trace_leave(lua_State *L)
19361968
** leader owns its consumed poll. Do not change any payload or pin until
19371969
** native_leave has closed native state and returned from that wait. */
19381970
actions = lj_native_leave(L);
1971+
#if defined(LJ_XSAVE_TEST_HELPERS)
1972+
{
1973+
/* Deterministic injection point after the native poll and before the
1974+
** fresh-STOPREQ decision. Production builds contain no hook load/call. */
1975+
LJFFINativeTraceLeaveHook hook =
1976+
la_loadfunc_acq(&ffi_native_trace_leave_hook);
1977+
if (hook)
1978+
hook(tg);
1979+
}
1980+
#endif
19391981
/* This predicate may perform one final owner poll, but never throws. If it
19401982
** reports a fresh STOPREQ, no generated post-call guard can be relied upon:
19411983
** the frame and pin must be released before checkstop unwinds the trace. */

src/lj_ccall.h

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,14 @@ LJ_FUNC uint64_t lj_ffi_native_frame_sequence_acq(const TGState *tg);
215215
LJ_FUNC uint32_t lj_ffi_native_frame_depth_acq(const TGState *tg);
216216

217217
#if LJ_HASJIT
218-
/* Owner-only XSAVE consumer for the future generic IR_CALLXS seam. Entry is
219-
** allocation-free and nonwaiting; zero requests a pre-call side exit without
220-
** consuming XSAVE state. Ordinary leave removes the frame. A forced leave
221-
** converts it to POSTCALL and retains its exact pin until the unconditional
222-
** caller-state trace exit invokes cleanup. Nested/callback entry remains gated
223-
** until suspended-frame publication exists. No recorder emits these calls yet.
218+
/* Owner-only XSAVE consumer for the generic IR_CALLXS seam. Entry is
219+
** allocation-free and nonwaiting; every attempt consumes XSAVE staging and
220+
** zero requests a pre-call interpreter exit. Ordinary leave removes the frame.
221+
** A forced leave converts it to POSTCALL and retains its exact pin until the
222+
** non-side-linkable caller-state trace exit invokes cleanup. The default
223+
** recorder remains gated; explicit test-only activation exercises the admitted
224+
** scalar lifecycle. Nested/callback entry remains gated until suspended-frame
225+
** publication exists.
224226
*/
225227
#define LJ_FFI_NATIVE_LEAVE_FORCE_EXIT 0x80000000u
226228
LJ_FUNC int lj_ffi_native_trace_enter(lua_State *L, struct GCtrace *T,
@@ -231,8 +233,11 @@ LJ_FUNC int lj_ffi_native_trace_exit_cleanup(lua_State *L,
231233
uint32_t traceno);
232234
#if defined(LJ_XSAVE_TEST_HELPERS)
233235
typedef void (*LJFFINativeTraceFinishHook)(TGState *tg);
236+
typedef void (*LJFFINativeTraceLeaveHook)(TGState *tg);
234237
LJ_FUNC void lj_ffi_native_trace_test_set_finish_hook(
235238
LJFFINativeTraceFinishHook hook);
239+
LJ_FUNC void lj_ffi_native_trace_test_set_leave_hook(
240+
LJFFINativeTraceLeaveHook hook);
236241
#endif
237242
#endif
238243
#if defined(LJ_FFI_NATIVE_FRAME_TEST_HELPERS)

0 commit comments

Comments
 (0)