Skip to content

Commit bd545c9

Browse files
committed
ffi: harden indirect CALLXS result storage
1 parent c544c5a commit bd545c9

9 files changed

Lines changed: 502 additions & 58 deletions

notes/ffi-callxs-indirect-aggregate-results-2026-07-18.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ operand. The backend therefore uses `lj_cdata_newv()` and returns an interior
9393
GCcdata header whose payload satisfies the declared alignment. Ordinary
9494
aggregates retain the cheaper fixed allocation path.
9595

96+
The interpreter now uses the same combined result info when it preallocates
97+
aggregate or complex return storage. Previously it retained the declared CType
98+
ID but called the ordinary allocator, so a cold over-aligned sret call could
99+
hand native code an under-aligned result buffer. This is a common FFI
100+
correctness fix rather than a JIT-only semantic change.
101+
102+
Non-aggregate boxed results deliberately remain different: the interpreter
103+
strips outer attributes from integer, pointer, and enum results before boxing.
104+
CALLXS therefore allocates those results with the resolved raw CType ID too.
105+
Keeping the declared ID for every rooted result would make interpreter and JIT
106+
`ffi.typeof()` disagree and could claim alignment the fixed box did not have.
107+
96108
## Compatibility and cost
97109

98110
Lua receives the same by-value cdata type and field bytes as the interpreter.
@@ -114,10 +126,13 @@ unchanged.
114126
The authentic fixture exports two unrelated 24-byte struct result types with
115127
different field layouts and scalar argument mixtures, plus an exact 24-byte,
116128
32-byte-aligned result which forces the interior aligned-cdata allocation
117-
path. Tests require exact CType identity, size/alignment, every field value,
118-
exact native effect counts, and production `XSAVE`/`CALLXS` IR for all three
119-
functions. Separate ignored, excess-fixed, open, CALLM, CALLT, and CALLMT cases
120-
cover the admitted caller/result modes.
129+
path. A zero-public-argument result proves the hidden pointer can be the whole
130+
argument tree, a union proves the admission is not struct-tag-specific, and an
131+
eight-GPR input forces public arguments onto the stack after the hidden ABI
132+
slot. Tests require exact CType identity, size/alignment, every field value,
133+
exact native effect counts, and production `XSAVE`/`CALLXS` IR. Separate
134+
ignored, excess-fixed, open, CALLM, CALLT, and CALLMT cases cover the admitted
135+
caller/result modes.
121136

122137
The C IR inspector distinguishes scalar boxed results (one raw `XSTORE`) from
123138
indirect aggregate results (`IRT_NIL`, no `XSTORE`, and exactly one payload
@@ -127,6 +142,11 @@ the returned payload address modulo 32. Forced POSTCALL, deliberate entry
127142
rejection, and fresh STOPREQ cases verify exact restoration, no completed-call
128143
replay, and empty native-frame/trace-pin state.
129144

145+
A blocked generated sret call is also held ACTIVE while a remote thread
146+
completes a full GC and global `jit.flush()`. The result box is reachable only
147+
through the certified native frame during that interval; exact counters remain
148+
at eight before release and prove the completed call is never replayed.
149+
130150
Focused validation passed the complete production CALLXS lifecycle suite, the
131151
full generic scalar/boxed ABI matrix, target-runtime Clang ASan, the documented
132152
Clang UBSan profile, and clean default, `LUAJIT_DISABLE_JIT`, and

notes/ffi-callxs-rooted-boxed-admission-2026-07-18.md

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@ activation gate: these result classes use the same ABI-driven argument
99
recorder, native-frame lifecycle, and CALLXS emission as the unboxed scalar
1010
classes.
1111

12-
Bool remains interpreted because normalization still needs a replay-safe
13-
post-side-effect contract. Aggregate and multi-register results remain future
14-
work. No file under `plan/` was changed.
12+
At this checkpoint bool remained interpreted and aggregate results remained
13+
future work. Subsequent same-day tranches admitted replay-safe bool and
14+
ABI-indirect aggregate results; this note is retained as the historical boxed
15+
admission checkpoint. No file under `plan/` was changed.
1516

1617
## Recorder protocol
1718

1819
The recorder captures the exact raw child CType ID, immutable return info,
1920
size, and raw IR type before recording arguments. This is necessary because
2021
vararg inference may grow and relocate the CType table. Raw-child lookup strips
21-
attributes exactly as the interpreter does, while preserving reference and enum
22-
identity. Consequently attributed integer typedefs, explicit C references, and
23-
enum results retain the correct box type without consulting a stale CType
24-
pointer.
22+
outer attributes exactly as the interpreter does, while preserving reference
23+
and enum identity. Consequently attributed integer, pointer, and enum
24+
declarations produce the same raw box CType as a cold call; explicit C
25+
references remain references because `CTF_REF` is not an outer attribute node.
26+
No classification consults a stale CType pointer.
2527

2628
After all argument guards and conversions, a boxed result follows this order:
2729

@@ -56,12 +58,13 @@ managed-edge aggregates without a write-barrier design.
5658

5759
## Compatibility and cost
5860

59-
The public LuaJIT API and ABI are unchanged. The interpreter-visible values
60-
keep their exact CType identity, including reference and attributed return
61-
types. The generated path allocates the result box immediately before the
62-
foreign call instead of immediately after it; this enables safe rooting without
63-
adding a signature-specific wrapper or a post-return allocation. The only
64-
post-return addition is the raw payload store.
61+
The public LuaJIT API and ABI are unchanged. Generated values keep the
62+
interpreter's exact resolved CType identity: outer scalar/pointer/enum
63+
attributes are stripped, enums retain their enum node, and references retain
64+
their reference node. The generated path allocates the result box immediately
65+
before the foreign call instead of immediately after it; this enables safe
66+
rooting without adding a signature-specific wrapper or a post-return
67+
allocation. The only post-return addition is the raw payload store.
6568

6669
As previously authorized for the beta line, custom `lua_Alloc` remains
6770
temporarily ignored by the GC2/internal allocator policy. This admission uses
@@ -72,8 +75,9 @@ silently treated as complete.
7275
## Evidence
7376

7477
- The authentic Lua fixture mechanically requires CALLXS for pointer,
75-
reference, enum, i64, u64, high-bit u64, and an attributed u64 typedef; bool
76-
mechanically remains interpreted.
78+
reference, enum, i64, u64, high-bit u64, and attributed integer/pointer/enum
79+
declarations. JIT-disabled baselines require exact `ffi.typeof()` and
80+
`ffi.alignof()` equality after outer attributes are stripped.
7781
- The generated ABI catalogue requires the generic path for all 320 admitted
7882
rows: 211 unboxed scalar rows and 109 newly admitted boxed rows.
7983
- IR inspection proves one exact `IR_CNEW` root, inclusion only in the XSAVE
@@ -99,12 +103,13 @@ silently treated as complete.
99103
Clean default, `LUAJIT_DISABLE_JIT`, and `LUAJIT_DISABLE_FFI` builds and runtime
100104
smokes pass independently of the sanitizer builds.
101105

102-
## Next work
106+
## Next work at this checkpoint
103107

104-
The immediate FFI/JIT follow-ups are replay-safe bool results, broader
105-
protected/continuation and root/tail trace shapes, and aggregate or
106-
multi-register ABI results. These remain generic ABI-lowering problems; they
107-
must not reintroduce explicit C-signature matching. Cross-platform deep
108-
validation and performance work remain b1.2.1 tasks, with the temporary custom
109-
allocator exception and the separate Lua `atomic` library scheduled after the
110-
core correctness tranches.
108+
Replay-safe bool and ABI-indirect aggregate results were the immediate
109+
follow-ups and are now covered by their later notes. Broader
110+
protected/continuation and root/tail trace shapes, direct/multi-register
111+
aggregate ABI results, cross-platform deep validation, and performance work
112+
remain b1.2.1 tasks. They must remain generic ABI-lowering work and must not
113+
reintroduce explicit C-signature matching. The temporary custom allocator
114+
exception and separate Lua `atomic` library remain scheduled after the core
115+
correctness tranches.

src/lj_ccall.c

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,8 @@ static int ccall_set_args(lua_State *L, CTState *cts, CType *ct,
11351135
CTypeID fid;
11361136
CTInfo info = ctype_info_acq(ct); /* Vararg inference may invalidate ct. */
11371137
CType ctrsnap, *ctr;
1138+
CTInfo result_info;
1139+
CTSize result_size;
11381140
MSize maxgpr, ngpr = 0, nsp = 0, narg;
11391141
#if CCALL_NARG_FPR
11401142
MSize nfpr = 0;
@@ -1162,17 +1164,21 @@ static int ccall_set_args(lua_State *L, CTState *cts, CType *ct,
11621164
#endif
11631165

11641166
/* Perform required setup for some result types. */
1165-
ctr = ccall_rawchild_wait(L, cts, ct, NULL, &ctrsnap);
1167+
if (lj_ctype_info_wait(L, cts, ctype_cid(info), &result_info,
1168+
&result_size, NULL, &ctrsnap) <= 0)
1169+
lj_err_caller(L, LJ_ERR_FFI_INVTYPE);
1170+
ctr = &ctrsnap;
11661171
{
11671172
CTInfo rinfo = ctype_info_acq(ctr);
1168-
CTSize rsize = ctype_size_acq(ctr);
1173+
CTSize rsize = result_size;
11691174
if (ctype_isvector(rinfo)) {
11701175
if (!(CCALL_VECTOR_REG && (rsize == 8 || rsize == 16)))
11711176
goto err_nyi;
11721177
} else if (ctype_iscomplex(rinfo) || ctype_isstruct(rinfo)) {
11731178
/* Preallocate cdata object and anchor it after arguments. */
11741179
CTSize sz = rsize;
1175-
GCcdata *cd = lj_cdata_new_l(L, cts, ctype_cid(info), sz);
1180+
GCcdata *cd = lj_cdata_newx_l(L, cts, ctype_cid(info), sz,
1181+
result_info);
11761182
void *dp = cdataptr(cd);
11771183
ccall_push_cdata_root(L, cd);
11781184
*has_result_root = 1;

src/lj_crecord.c

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2364,7 +2364,7 @@ static int crec_call(jit_State *J, RecordFFData *rd, GCcdata *cd)
23642364
TRef args, entered, func, callfunc, tr, result_box, result_payload;
23652365
CType ctrsnap;
23662366
CType *ctr;
2367-
CTypeID result_id;
2367+
CTypeID result_id, raw_result_id, result_box_id;
23682368
CTInfo ctr_info, result_info;
23692369
CTSize result_size;
23702370
MSize nargs;
@@ -2391,22 +2391,25 @@ static int crec_call(jit_State *J, RecordFFData *rd, GCcdata *cd)
23912391
}
23922392

23932393
func = emitir(IRT(IR_FLOAD, tp), J->base[0], IRFL_CDATA_PTR);
2394-
/* Preserve the declared return CType ID on the cdata, exactly like the
2395-
** interpreter. Classification uses the resolved raw child snapshot. */
2394+
/* Aggregates retain the declared return CType ID because the interpreter
2395+
** preallocates their storage from the function child. Scalar, pointer and
2396+
** enum conversion strips outer attributes, so their boxes must use the raw
2397+
** ID to remain exactly interpreter-compatible. */
23962398
result_id = ctype_cid(info);
23972399
/* Resolve the declared result once, retaining both the raw layout child
23982400
** and the accumulated attributes. In particular, an outer CTA_ALIGN must
23992401
** survive resolution so CNEW selects lj_cdata_newv before its payload
24002402
** is exposed as the ABI sret pointer. */
24012403
if (!crec_direct_ctype_info(J, cts, result_id, &result_info, &result_size,
2402-
NULL, &ctrsnap))
2404+
&raw_result_id, &ctrsnap))
24032405
lj_trace_err(J, LJ_TRERR_BADTYPE);
24042406
ctr = &ctrsnap;
24052407
ctr_info = ctype_info_acq(ctr);
24062408
/* Enum children live in the relocatable CType table too. Even rejected
24072409
** result classes must use the recorder's snapshot/retry reader contract. */
24082410
t = crec_ct2irt_snapshot(J, cts, ctr);
24092411
indirect_result = crec_call_indirect_struct_result(ctr_info, result_size);
2412+
result_box_id = indirect_result ? result_id : raw_result_id;
24102413
if (ctype_isvoid(ctr_info)) {
24112414
t = IRT_NIL;
24122415
rd->nres = 0;
@@ -2456,7 +2459,7 @@ static int crec_call(jit_State *J, RecordFFData *rd, GCcdata *cd)
24562459
/* Over-aligned sret storage must take the CNEW/newv path. The returned
24572460
** GCcdata is an interior aligned header, so payload is still cd+1. */
24582461
result_box = emitir(IRTG(IR_CNEW, IRT_CDATA),
2459-
lj_ir_kint(J, result_id),
2462+
lj_ir_kint(J, result_box_id),
24602463
indirect_result &&
24612464
ctype_align(result_info) > CT_MEMALIGN ?
24622465
lj_ir_kint(J, result_size) : TREF_NIL);

tests/t-ffi-callxs-authentic-lib.c

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ static int32_t lj_callxs_auth_reference_value = 0x345678;
1010
static uint32_t lj_callxs_auth_aggregate_alpha_counter;
1111
static uint32_t lj_callxs_auth_aggregate_beta_counter;
1212
static uint32_t lj_callxs_auth_aggregate_aligned_counter;
13+
static uint32_t lj_callxs_auth_aggregate_zero_counter;
14+
static uint32_t lj_callxs_auth_aggregate_union_counter;
15+
static uint32_t lj_callxs_auth_aggregate_wide_counter;
1316

1417
/* These deliberately unrelated 24-byte return types both use the fixed-size
1518
** aggregate sret ABI on the supported x64 targets. Their distinct field
@@ -33,6 +36,15 @@ typedef struct {
3336
uint64_t lane[3];
3437
} lj_callxs_auth_aggregate_aligned __attribute__((aligned(32)));
3538

39+
union lj_callxs_auth_aggregate_union {
40+
uint64_t lane[3];
41+
struct {
42+
int64_t signed_lane;
43+
double ratio;
44+
uint64_t token;
45+
} fields;
46+
};
47+
3648
typedef char lj_callxs_auth_aggregate_alpha_size[
3749
sizeof(struct lj_callxs_auth_aggregate_alpha) == 24 ? 1 : -1];
3850
typedef char lj_callxs_auth_aggregate_beta_size[
@@ -41,6 +53,8 @@ typedef char lj_callxs_auth_aggregate_aligned_size[
4153
sizeof(lj_callxs_auth_aggregate_aligned) == 24 ? 1 : -1];
4254
typedef char lj_callxs_auth_aggregate_aligned_align[
4355
__alignof__(lj_callxs_auth_aggregate_aligned) == 32 ? 1 : -1];
56+
typedef char lj_callxs_auth_aggregate_union_size[
57+
sizeof(union lj_callxs_auth_aggregate_union) == 24 ? 1 : -1];
4458

4559
int32_t lj_callxs_auth_add(int32_t a, int32_t b)
4660
{
@@ -124,6 +138,11 @@ int32_t *lj_callxs_auth_ptr(int32_t *p)
124138
return p;
125139
}
126140

141+
int32_t *lj_callxs_auth_attributed_ptr_result(int32_t *p)
142+
{
143+
return p;
144+
}
145+
127146
_Bool lj_callxs_auth_bool(int32_t value)
128147
{
129148
lj_callxs_auth_counter++;
@@ -157,6 +176,13 @@ enum lj_callxs_auth_enum lj_callxs_auth_enum_result(int32_t value)
157176
return LJ_CALLXS_AUTH_ENUM_SEVEN;
158177
}
159178

179+
enum lj_callxs_auth_enum
180+
lj_callxs_auth_attributed_enum_result(int32_t value)
181+
{
182+
(void)value;
183+
return LJ_CALLXS_AUTH_ENUM_SEVEN;
184+
}
185+
160186
int64_t lj_callxs_auth_i64_result(int32_t value)
161187
{
162188
(void)value;
@@ -221,11 +247,55 @@ lj_callxs_auth_aggregate_aligned_result(uint64_t seed)
221247
return result;
222248
}
223249

250+
struct lj_callxs_auth_aggregate_alpha
251+
lj_callxs_auth_aggregate_zero_result(void)
252+
{
253+
struct lj_callxs_auth_aggregate_alpha result;
254+
lj_callxs_auth_counter++;
255+
lj_callxs_auth_aggregate_zero_counter++;
256+
result.cookie = UINT64_C(0x0102030405060708);
257+
result.weight = 6.25;
258+
result.code = INT32_C(-90210);
259+
result.stamp = UINT32_C(0xc001d00d);
260+
return result;
261+
}
262+
263+
union lj_callxs_auth_aggregate_union
264+
lj_callxs_auth_aggregate_union_result(uint64_t seed)
265+
{
266+
union lj_callxs_auth_aggregate_union result;
267+
lj_callxs_auth_counter++;
268+
lj_callxs_auth_aggregate_union_counter++;
269+
result.lane[0] = UINT64_C(0x8899aabbccdd0000) + seed;
270+
result.lane[1] = UINT64_C(0x3ff4000000000000);
271+
result.lane[2] = UINT64_C(0x1020304050600000) + seed;
272+
return result;
273+
}
274+
275+
struct lj_callxs_auth_aggregate_beta
276+
lj_callxs_auth_aggregate_wide_result(uint64_t a, uint64_t b, uint64_t c,
277+
uint64_t d, uint64_t e, uint64_t f,
278+
uint64_t g, uint64_t h)
279+
{
280+
struct lj_callxs_auth_aggregate_beta result;
281+
uint64_t sum = a + b + c + d + e + f + g + h;
282+
lj_callxs_auth_counter++;
283+
lj_callxs_auth_aggregate_wide_counter++;
284+
result.debt = -(int64_t)sum;
285+
result.stamp = UINT32_C(0x600d0000) + (uint32_t)sum;
286+
result.ratio = (float)sum * 0.25f;
287+
result.token = UINT64_C(0xabcdef0000000000) + sum;
288+
return result;
289+
}
290+
224291
void lj_callxs_auth_aggregate_reset(void)
225292
{
226293
lj_callxs_auth_aggregate_alpha_counter = 0;
227294
lj_callxs_auth_aggregate_beta_counter = 0;
228295
lj_callxs_auth_aggregate_aligned_counter = 0;
296+
lj_callxs_auth_aggregate_zero_counter = 0;
297+
lj_callxs_auth_aggregate_union_counter = 0;
298+
lj_callxs_auth_aggregate_wide_counter = 0;
229299
}
230300

231301
uint32_t lj_callxs_auth_aggregate_alpha_count(void)
@@ -243,6 +313,21 @@ uint32_t lj_callxs_auth_aggregate_aligned_count(void)
243313
return lj_callxs_auth_aggregate_aligned_counter;
244314
}
245315

316+
uint32_t lj_callxs_auth_aggregate_zero_count(void)
317+
{
318+
return lj_callxs_auth_aggregate_zero_counter;
319+
}
320+
321+
uint32_t lj_callxs_auth_aggregate_union_count(void)
322+
{
323+
return lj_callxs_auth_aggregate_union_counter;
324+
}
325+
326+
uint32_t lj_callxs_auth_aggregate_wide_count(void)
327+
{
328+
return lj_callxs_auth_aggregate_wide_counter;
329+
}
330+
246331
void lj_callxs_auth_reset(void)
247332
{
248333
lj_callxs_auth_counter = 0;

0 commit comments

Comments
 (0)