Skip to content

Native.register (direct mapping) corrupts/crashes on stack-spilled Structure.ByValue arguments on macOS arm64 #1736

Description

@metasim

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), not Native.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 11 Structure.ByValue args 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 the Structure.ByValue run, which is the shape every uniffi-generated call actually uses (a trailing RustCallStatus* out-param). That suggests the bug is specifically in how libffi computes/advances the stack offset once a run of stack-spilled Structure.ByValue arguments is followed by a differently-marshalled (by-reference) argument, not simply "many Structure.ByValue args."
  • An identical call shape using 11 plain long arguments instead of Structure.ByValue args (still followed by the trailing by-reference CallStatus) 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions