java-native-access / java-native-access/jna
Native.register (direct mapping) corrupts/crashes on stack-spilled Structure.ByValue arguments on macOS arm64
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 8.9k
- Forks
- 1.7k
- PR merge metrics
- No merged PRs in 30d
Description
[!NOTE]
AI was used to analyze this bug and create a minimal test case.
Environment
- JNA version: 5.18.1 (also reproduces on 5.19.1, the latest release as of this report)
- OS: macOS 26.5.2 (25F84), Darwin 25.5.0
- Arch:
aarch64(Apple Silicon,Mac15,11) - JDK: OpenJDK 17.0.16 (Homebrew build) — also reproduces on OpenJDK 26.0.1
- Invocation style:
Native.register(direct mapping), notNative.load/interface mapping
Summary
A native function called via Native.register that takes several Structure.ByValue arguments (each a 24-byte struct: two long fields + one pointer field — an AAPCS64 MEMORY-class aggregate, passed by hidden reference) receives corrupted/garbage data in the arguments that spill past the 8 available integer/pointer argument registers. In our reduced repro this manifests as a hard SIGSEGV inside JNA's bundled libffi's ffi_call, invoked from Java_com_sun_jna_Native_registerMethod. The same call works correctly on x86_64 Linux with an identical argument count and struct shape.
We hit this indirectly through mozilla/uniffi-rs, whose generated Kotlin bindings use Native.register and represent every non-primitive argument (records, options, strings) as a RustBuffer.ByValue struct — { capacity: u64, len: u64, data: *mut u8 }, i.e. exactly the 24-byte shape below. A UniFFI-exported Rust function/constructor with 9+ such arguments reliably crashes the JVM on Apple Silicon macOS while working fine on Linux. This report reduces that down to a minimal, uniffi-independent, pure C + JNA repro.
Reproduction
repro.c — compile with clang -dynamiclib -o libjnarepro.dylib repro.c:
#include <stdint.h>
#include <string.h>
#include <stdio.h>
typedef struct {
uint64_t capacity;
uint64_t len;
void *data;
} Buf; /* 24 bytes: MEMORY-class aggregate on AAPCS64, passed by hidden pointer */
typedef struct {
int8_t code;
Buf error_buf;
} CallStatus;
uint64_t dump11(Buf a1, Buf a2, Buf a3, Buf a4, Buf a5, Buf a6, Buf a7, Buf a8,
Buf a9, Buf a10, Buf a11, CallStatus *out_status) {
Buf *args[11] = {&a1,&a2,&a3,&a4,&a5,&a6,&a7,&a8,&a9,&a10,&a11};
for (int i = 0; i < 11; i++) {
fprintf(stdout, "arg%d capacity=%llu len=%llu%s\n", i + 1,
(unsigned long long)args[i]->capacity,
(unsigned long long)args[i]->len,
args[i]->capacity == (uint64_t)(i + 1) ? "" : " <-- CORRUPTED");
}
memset(out_status, 0, sizeof(*out_status));
return 0;
}
Repro.java — compile/run with the JNA jar on the classpath, -Djna.library.path=. pointing at the directory containing libjnarepro.dylib:
import com.sun.jna.Native;
import com.sun.jna.Structure;
public class Repro {
@Structure.FieldOrder({"capacity", "len", "data"})
public static class Buf extends Structure {
public long capacity;
public long len;
public com.sun.jna.Pointer data;
public static class ByValue extends Buf implements Structure.ByValue {}
static ByValue of(long tag) {
ByValue b = new ByValue();
b.capacity = tag;
b.len = tag;
b.data = null;
return b;
}
}
@Structure.FieldOrder({"code", "error_buf"})
public static class CallStatus extends Structure {
public byte code;
public Buf error_buf;
}
static class DirectLib {
static {
Native.register(DirectLib.class, "jnarepro");
}
static native long dump11(Buf.ByValue a1, Buf.ByValue a2, Buf.ByValue a3, Buf.ByValue a4,
Buf.ByValue a5, Buf.ByValue a6, Buf.ByValue a7, Buf.ByValue a8,
Buf.ByValue a9, Buf.ByValue a10, Buf.ByValue a11, CallStatus outStatus);
}
public static void main(String[] args) {
System.out.println("jna.version = " + Native.VERSION + " os.arch=" + System.getProperty("os.arch"));
CallStatus s = new CallStatus();
DirectLib.dump11(
Buf.of(1), Buf.of(2), Buf.of(3), Buf.of(4), Buf.of(5),
Buf.of(6), Buf.of(7), Buf.of(8), Buf.of(9), Buf.of(10), Buf.of(11), s);
}
}
Each Buf.of(tag) sets capacity = len = tag, so a correct call prints arg1 capacity=1 ... arg11 capacity=11 with no CORRUPTED markers.
Actual output (macOS arm64)
jna.version = 5.18.1 os.arch=aarch64
arg1 capacity=1 len=1
arg2 capacity=2 len=2
arg3 capacity=3 len=3
arg4 capacity=4 len=4
arg5 capacity=5 len=5
arg6 capacity=6 len=6
arg7 capacity=7 len=7
arg8 capacity=8 len=8
arg9 capacity=9 len=9
#
# A fatal error has been detected by the Java Runtime Environment:
#
# SIGSEGV (0xb) at pc=0x00000001021244f8, pid=41852, tid=5123
# ...
# Problematic frame:
# C [libjnarepro.dylib+0x4f8] dump11+0xe8
Native frames from the hs_err file:
C [libjnarepro.dylib+0x4f8] dump11+0xe8
C [jna...tmp+0x1004c] ffi_prep_closure_loc+0x1970
C [jna...tmp+0xe64c] ffi_call+0x584
C [jna...tmp+0xae3c] Java_com_sun_jna_Native_registerMethod+0xe8c
C [jna...tmp+0xeb48] ffi_prep_closure_loc+0x46c
C [jna...tmp+0x101d4] ffi_prep_closure_loc+0x1af8
j Repro$DirectLib.dump11(...)J+0
j Repro.main([Ljava/lang/String;)V+108
Arguments 1–9 (which fit in the 8 available AAPCS64 integer/pointer argument registers plus one that's actually classified as taken by the hidden return-value/indirect-result register — the exact boundary varies slightly by call shape) print correctly. The crash occurs while the callee reads argument 10 — the second argument that had to spill onto the stack — meaning the pointer JNA/libffi placed on the outgoing call's stack for that argument is invalid. The fault is inside JNA's own bundled libffi's ffi_call, not in the generated dump11 code or in Java-level marshalling.
Reducing further:
- With the trailing
CallStatus *(by-reference) argument removed entirely — just 9 or 11Structure.ByValueargs and nothing else — we saw no corruption at all in our testing; every argument came back with its expected tag. The corruption/crash only appeared once a trailing struct-by-reference out-parameter was added after theStructure.ByValuerun, which is the shape every uniffi-generated call actually uses (a trailingRustCallStatus*out-param). That suggests the bug is specifically in how libffi computes/advances the stack offset once a run of stack-spilledStructure.ByValuearguments is followed by a differently-marshalled (by-reference) argument, not simply "manyStructure.ByValueargs." - An identical call shape using 11 plain
longarguments instead ofStructure.ByValueargs (still followed by the trailing by-referenceCallStatus) does not crash or corrupt. - I have not yet run this exact minimal repro on x86_64 Linux myself, but the originating uniffi-rs-generated code (same argument count and struct shape, called the same way) passes reliably in our Linux x86_64 CI and only fails on macOS arm64 — happy to run the minimal repro on Linux too if that's useful before this is investigated.
Expected behavior
Structure.ByValue arguments passed via Native.register should marshal correctly regardless of how many of them are needed to spill from registers onto the outgoing call's stack, on all supported architectures including macOS arm64.
Notes / related issues
This looks related in spirit to #1259 ("Passing structs by value on the stack is incorrect on arm64"), but is not obviously the same bug: #1259's repro struct is 8 bytes (a single int64_t field), which AAPCS64 classifies as a register-class/INTEGER aggregate passed directly in one register or one stack slot. The struct here is 24 bytes, which AAPCS64 always classifies as MEMORY-class and passes by hidden pointer regardless of position — a different code path in libffi's arm64 support. #1259 was reported fixed upstream as a side effect of an unrelated libffi refactor; this issue reproduces on the current release (5.19.1), so if it is the same underlying class of bug, that fix does not cover this code path.
It may also be related to the struct-by-value/callback crashes on Darwin arm64 acknowledged in #1238 and tracked in #1323 (partially addressed for direct callbacks in 5.11.0 per the changelog), though those predate the current libffi bundled in 5.18.1+ and it's unclear whether they cover plain (non-callback) direct-mapped function arguments.
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
Reproduce the failure with repro.c and Repro.java on macOS arm64, then trace Native_registerMethod into the bundled libffi ffi_call path for stack-spilled Structure.ByValue arguments. Done means the call completes without corruption or SIGSEGV and prints the expected capacity and len values for all 11 arguments, including the trailing CallStatus pointer.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, java
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100