javacjava-ffm

Weird behaviour of Java FFM on Windows platform when creating upcalls accepting both structure and pointer parameters


On Windows platform, when creating upcall stubs with Java 22 FFM APIs, if the callback functions has both structure (larger than pointer size) and pointer parameters, the MemorySegments accepting these pointer parameters will be linked with a confined memory session. This behavior is not consistent with Linux JVM.

MWE here:


ccb.c:

#include <stdio.h>
#include <stdint.h>

typedef struct {
    char const *s1;
    char const *s2;
} S;

typedef void (*callback_fn)(S s, char const *data);

#ifdef _MSC_VER
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __attribute__((visibility("default")))
#endif

EXPORT extern void ccb(callback_fn fn) {
    char const *data = "Let's be together, forever, we are never gonna be apart.";

    fprintf(stderr, "(C) address of data = %p\n", (void*)data);

    fn(
        ((S) {
            "Shall I leave you be, Is it love if I can set you free?",
            "But even it's not reality,",
        }),
        data
    );
}

Here, the data provided to fn is a static string from C world. It's not allocated from Java world, of course.


Build commands:

gcc.exe ccb.c -shared -fPIC -o ccb.dll

or

cl.exe /utf-8 /nologo /LD /MD /DWIN32 /Zi ccb.c /Fe:ccb.dll /link User32.lib

CCB.java:

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;

public final class CCB {
    static final StructLayout LAYOUT$S = MemoryLayout.structLayout(
            ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_BYTE).withName("s1"),
            ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_BYTE).withName("s2")
    );

    static final FunctionDescriptor DESCRIPTOR$ccb = FunctionDescriptor.ofVoid(ValueLayout.ADDRESS.withName("fn"));

    static final FunctionDescriptor DESCRIPTOR$callback = FunctionDescriptor.ofVoid(
            LAYOUT$S.withName("s"),
            ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_BYTE).withName("data")
    );

    static final MethodHandle hCCB;
    static {
        System.loadLibrary("ccb");

        Linker linker = Linker.nativeLinker();
        SymbolLookup stdlibLookup = linker.defaultLookup();
        SymbolLookup loaderLookup = SymbolLookup.loaderLookup();

        MemorySegment pfnCCB = loaderLookup.find("ccb")
                .or(() -> stdlibLookup.find("ccb"))
                .orElse(MemorySegment.NULL);
        if (pfnCCB.equals(MemorySegment.NULL)) {
            throw new RuntimeException("Failed to find ccb symbol");
        }
        hCCB = linker.downcallHandle(pfnCCB, DESCRIPTOR$ccb);
    }

    static final class Ref<T> {
        T value;
    }

    @FunctionalInterface
    interface MemorySegmentConsumer {
        void accept(MemorySegment segment);
    }

    static void callback(
            MemorySegmentConsumer consumer,
            MemorySegment s,
            MemorySegment data
    ) {
        for (int i = 0; i < 2; i++) {
            MemorySegment segment = s.getAtIndex(ValueLayout.ADDRESS, i).reinterpret(Long.MAX_VALUE);
            System.err.println("(J) callback: s->s" + (i + 1) + " = " + segment.getString(0));
        }
        data = data.reinterpret(Long.MAX_VALUE);
        System.err.println("(J) callback: data = " + data.getString(0));
        System.err.println("(J) callback: address of data = " + Long.toUnsignedString(data.address(), 16));

        consumer.accept(data);
    }

    public static void main(String[] args) {
        Ref<MemorySegment> ref = new Ref<>();
        MemorySegmentConsumer consumer = segment -> ref.value = segment;

        try (Arena arena = Arena.ofConfined()) {
            Linker linker = Linker.nativeLinker();
            MethodHandle MH$callback = MethodHandles.lookup().findStatic(
                    CCB.class,
                    "callback",
                    DESCRIPTOR$callback.toMethodType().insertParameterTypes(0, MemorySegmentConsumer.class)
            );
            MemorySegment pfnCallback = linker.upcallStub(
                    MH$callback.bindTo(consumer),
                    DESCRIPTOR$callback,
                    arena
            );

            hCCB.invokeExact(pfnCallback);

            System.err.println("(J) main: data = " + ref.value.getString(0)); // <-- error occurs here
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }
}

Program output:

(J) callback: s->s1 = Shall I leave you be, Is it love if I can set you free?
(J) callback: s->s2 = But even it's not reality,
(J) callback: data = Let's be together, forever, we are never gonna be apart.
(J) callback: address of data = 7fffa5009000
Exception in thread "main" java.lang.RuntimeException: java.lang.IllegalStateException: Already closed
    at CCB.main(CCB.java:81)
Caused by: java.lang.IllegalStateException: Already closed
    at java.base/jdk.internal.foreign.MemorySessionImpl.alreadyClosed(MemorySessionImpl.java:318)
    at java.base/jdk.internal.misc.ScopedMemoryAccess$ScopedAccessError.newRuntimeException(ScopedMemoryAccess.java:114)
    at java.base/jdk.internal.misc.ScopedMemoryAccess.getLongUnaligned(ScopedMemoryAccess.java:2574)
    at java.base/jdk.internal.foreign.StringSupport.strlenByte(StringSupport.java:142)
    at java.base/jdk.internal.foreign.StringSupport.readByte(StringSupport.java:71)
    at java.base/jdk.internal.foreign.StringSupport.read(StringSupport.java:54)
    at java.base/jdk.internal.foreign.AbstractMemorySegmentImpl.getString(AbstractMemorySegmentImpl.java:907)
    at java.base/jdk.internal.foreign.AbstractMemorySegmentImpl.getString(AbstractMemorySegmentImpl.java:900)
    at CCB.main(CCB.java:79)
(C) address of data = 00007fffa5009000

And when observed from IDE:

Observed ConfinedSession from IDE


Such behavior is not observed when there's only pointer parameters, and not observed when the structure is smaller (only 1 pointer size).

Also, this behavior is not observed on Linux:

Not observed on Linux

Is this some kind of deliberate design, technical limitation or bug?


Solution

  • You've found a bug in the FFM implementation.

    As an ABI implementation detail, sometimes a struct that is passed by-value is actually allocated on the stack of the caller, and then a pointer is passed to the callee. The FFM implementation handles that case similarly to plain pointers being passed. After all, in both cases the implementation has to wrap a raw native address (in the form of a long) into a memory segment.

    However, in the case of a struct, the resulting memory segment needs to have the right size, and the right memory session, which is open for the duration of the call (after which the temporary copy is de-allocated by the caller). First, when preparing to call the user code for the upcall, a memory session needs to be created, and then when wrapping the native address into a memory segment, the segment needs to be attached to the created session.

    The implementation has two checks: 1) a check whether any of the parameters need to be attached to a memory session, which tells us if we need to create a session for the upcall as a whole, and 2) a check whether a particular memory segment needs to be attached to that created session.

    The issue is that the first check is being used in both cases, so if there are any parameters that need to be attached to a memory session, all of them will be:

    private void emitBoxAddress(BoxAddress boxAddress) {
        popType(long.class);
        cb.loadConstant(boxAddress.size())
          .loadConstant(boxAddress.align());
        if (needsSession()) { // <-------- should be `boxAddress.needsSession()`
            emitLoadInternalSession();
            cb.invokestatic(CD_Utils, "longToAddress", MTD_LONG_TO_ADDRESS_SCOPE);
        } else {
            cb.invokestatic(CD_Utils, "longToAddress", MTD_LONG_TO_ADDRESS_NO_SCOPE);
        }
        pushType(MemorySegment.class);
    }
    

    Linux/x64 uses the SysV ABI, which doesn't have the quirk for passing structs mentioned above, so that explains why the issue is not observed there (however, Linux/AArch64 passes structs in a similar way, so the issue should also be observable there).


    As a workaround, you can manually attach the global scope to the memory address using reinterpret, which is the scope that it should have in the first place.

    data = data.reinterpret(Long.MAX_VALUE, Arena.global(), null);