bell-sw / LibericaNIK

Native Image Kit
4 stars 0 forks source link

NIK 23 with JavaFX FXML compatibility #17

Closed danielchemko closed 1 year ago

danielchemko commented 1 year ago

Hello, I'm trying out NIK 20.0.1+10 (bellsoft-liberica-vm-full-openjdk20-23.0.0) on OSX (M1 Pro) for my simple JavaFX application and I've been trying to compile it into native-image. This will be my first attempt at native JavaFX, so apologies if there's something obvious I've missed.

I have a few key JavaFX dependencies: javafx.controls, javafx.fxml. It seems like I'm getting hung up on the FXML reflection layer. Everything works fine in Java 17, 19, 20 running via JDK on my computer, and I've tried to follow https://www.graalvm.org/22.2/reference-manual/native-image/guides/use-native-image-maven-plugin/ on how to build native images using Maven tooling.

Run the application (with agent config capture) PATH=/opt/java/bellsoft-liberica-vm-full-openjdk20-23.0.0/Contents/Home/bin:$PATH GRAALVM_HOME=/opt/java/bellsoft-liberica-vm-full-openjdk20-23.0.0/Contents/Home JAVA_HOME=/opt/java/bellsoft-liberica-vm-full-openjdk20-23.0.0/Contents/Home mvn -Pnative -Dagent exec:exec@java-agent

Build the native image (with agent config) PATH=/opt/java/bellsoft-liberica-vm-full-openjdk20-23.0.0/Contents/Home/bin:$PATH GRAALVM_HOME=/opt/java/bellsoft-liberica-vm-full-openjdk20-23.0.0/Contents/Home JAVA_HOME=/opt/java/bellsoft-liberica-vm-full-openjdk20-23.0.0/Contents/Home mvn -Pnative -Dagent package -DskipTests

JavaFX startup code causing problems (Written in Kotlin, sorry)

    override fun start(stage: Stage) {
            val fxmlLoader = FXMLLoader(MyApplication::class.java.getResource("/my/stuff/firstscene.fxml"))
            val scene = Scene(fxmlLoader.load())
            val controller = fxmlLoader.getController<FirstSceneController>()
    }

I've tried several ways, but here are the results:

  1. Attempt without agent profiled -- The system boots as far as new FXMLLoader() before failing to find JavaFX primitives like javafx.scene.Button (Even with --add-modules=java.desktop,javafx.controls,javafx.graphics,javafx.fxml it doesn't seem to be detected as a core dependency most likely because of reflection).
  2. Attempt with agent profiled (no config) -- Error at the bottom of this post when attempting to run the compiled binary
  3. Attempt with agent profiled and more agent options (``` true true true true true
                        </agent>```)  -- Same error as  point 2, but during compilation, I got a hint on what's going wrong (`Skipped 1 predefined class(es) because the classpath already contains a class with the same name: com.sun.javafx.reflect.Trampoline`) so it looks like when I add the agent dependencies on JavaFX, its picking up the Trampoline and adding it to platform classloader instead of application? Trampoline seems to want to run only in derived classloaders, so it hard-bails.

I'm not entirely sure how I could force the trampoline to be skipped from the system classloader or to have the compiler not skip over its insertion when it sees a copy within the platform classloader.


    at javafx.base@20.0.1/com.sun.javafx.reflect.Trampoline.<clinit>(MethodUtil.java:51)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil.getTrampolineClass(MethodUtil.java:382)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil$1.run(MethodUtil.java:298)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil$1.run(MethodUtil.java:295)
    at java.base@20.0.1/java.security.AccessController.executePrivileged(AccessController.java:147)
    at java.base@20.0.1/java.security.AccessController.doPrivileged(AccessController.java:571)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil.getTrampoline(MethodUtil.java:294)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil.<clinit>(MethodUtil.java:82)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.MethodHelper.<clinit>(MethodHelper.java:44)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.ModuleHelper.invoke(ModuleHelper.java:100)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.BeanAdapter.put(BeanAdapter.java:259)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.BeanAdapter.put(BeanAdapter.java:54)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader$PropertyElement.set(FXMLLoader.java:1424)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader$ValueElement.processEndElement(FXMLLoader.java:803)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.processEndElement(FXMLLoader.java:2969)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2654)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2563)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.load(FXMLLoader.java:2531)
    at cloud.solarwinds.safety.calliope.CalliopeApplication.start(CalliopeApplication.kt:33)
    at javafx.graphics@20.0.1/com.sun.javafx.application.LauncherImpl.lambda$launchApplication1$9(LauncherImpl.java:839)
    at javafx.graphics@20.0.1/com.sun.javafx.application.PlatformImpl.lambda$runAndWait$12(PlatformImpl.java:483)
    at javafx.graphics@20.0.1/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:456)
    at java.base@20.0.1/java.security.AccessController.executePrivileged(AccessController.java:171)
    at java.base@20.0.1/java.security.AccessController.doPrivileged(AccessController.java:400)
    at javafx.graphics@20.0.1/com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:455)
    at javafx.graphics@20.0.1/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:95)
Exception in Application start method
java.lang.RuntimeException: Exception in Application start method
    at javafx.graphics@20.0.1/com.sun.javafx.application.LauncherImpl.launchApplication1(LauncherImpl.java:893)
    at javafx.graphics@20.0.1/com.sun.javafx.application.LauncherImpl.lambda$launchApplication$2(LauncherImpl.java:195)
    at java.base@20.0.1/java.lang.Thread.runWith(Thread.java:1636)
    at java.base@20.0.1/java.lang.Thread.run(Thread.java:1623)
    at org.graalvm.nativeimage.builder/com.oracle.svm.core.thread.PlatformThreads.threadStartRoutine(PlatformThreads.java:838)
    at org.graalvm.nativeimage.builder/com.oracle.svm.core.posix.thread.PosixPlatformThreads.pthreadStartRoutine(PosixPlatformThreads.java:211)
Caused by: java.lang.Error: Trampoline must not be defined by the platform classloader
    at javafx.base@20.0.1/com.sun.javafx.reflect.Trampoline.<clinit>(MethodUtil.java:51)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil.getTrampolineClass(MethodUtil.java:382)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil$1.run(MethodUtil.java:298)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil$1.run(MethodUtil.java:295)
    at java.base@20.0.1/java.security.AccessController.executePrivileged(AccessController.java:147)
    at java.base@20.0.1/java.security.AccessController.doPrivileged(AccessController.java:571)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil.getTrampoline(MethodUtil.java:294)
    at javafx.base@20.0.1/com.sun.javafx.reflect.MethodUtil.<clinit>(MethodUtil.java:82)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.MethodHelper.<clinit>(MethodHelper.java:44)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.ModuleHelper.invoke(ModuleHelper.java:100)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.BeanAdapter.put(BeanAdapter.java:259)
    at javafx.fxml@20.0.1/com.sun.javafx.fxml.BeanAdapter.put(BeanAdapter.java:54)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader$PropertyElement.set(FXMLLoader.java:1424)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader$ValueElement.processEndElement(FXMLLoader.java:803)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.processEndElement(FXMLLoader.java:2969)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2654)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2563)
    at javafx.fxml@20.0.1/javafx.fxml.FXMLLoader.load(FXMLLoader.java:2531)
    at cloud.solarwinds.safety.calliope.CalliopeApplication.start(CalliopeApplication.kt:33)
    at javafx.graphics@20.0.1/com.sun.javafx.application.LauncherImpl.lambda$launchApplication1$9(LauncherImpl.java:839)
    at javafx.graphics@20.0.1/com.sun.javafx.application.PlatformImpl.lambda$runAndWait$12(PlatformImpl.java:483)
    at javafx.graphics@20.0.1/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:456)
    at java.base@20.0.1/java.security.AccessController.executePrivileged(AccessController.java:171)
    at java.base@20.0.1/java.security.AccessController.doPrivileged(AccessController.java:400)
    at javafx.graphics@20.0.1/com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:455)
    at javafx.graphics@20.0.1/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:95)```
AlexanderScherbatiy commented 1 year ago

The issue is reproduced with a simple FXML sample on Linux as well.

The reason is that JavaFX Trampoline class checks that it is not defined on the bootstrap or platform classloader: https://github.com/openjdk/jfx/blob/a17a71458def91d206844b7d64e185af75a3c6e0/modules/javafx.base/src/main/java/com/sun/javafx/reflect/MethodUtil.java#L46

A simple Trampoline substitutor can workaround the problem in a project which uses FXML:

import com.oracle.svm.core.annotate.Substitute;
import com.oracle.svm.core.annotate.TargetClass;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.AccessController;

@TargetClass(className = "com.sun.javafx.reflect.Trampoline")
@Substitute
public final class com_sun_javafx_reflect_Trampoline {

    @Substitute
    private static void ensureInvocableMethod(Method m)
            throws InvocationTargetException {
        Class<?> clazz = m.getDeclaringClass();
        if (clazz.equals(AccessController.class) ||
                clazz.equals(Method.class) ||
                clazz.getName().startsWith("java.lang.invoke."))
            throw new InvocationTargetException(
                    new UnsupportedOperationException("invocation not supported"));
    }

    @Substitute
    private static Object invoke(Method m, Object obj, Object[] params)
            throws InvocationTargetException, IllegalAccessException {
        ensureInvocableMethod(m);
        return m.invoke(obj, params);
    }
}

The substitutor can be just copied to the project and SVM dependency added to the pom.xml (with the required version):

        <dependency>
            <groupId>org.graalvm.nativeimage</groupId>
            <artifactId>svm</artifactId>
            <version>23.0.0</version>
            <scope>provided</scope>
        </dependency>

The more rigorous fix can restrict the Trampolin.invoke(...) method to call only methods on classes or modules used only by FXML.

For example, on my simple FXML project the -Djavafx.verbose=true option prints:

java -Djavafx.verbose=true -jar target/fxmlsample-1.0-SNAPSHOT-jar-with-dependencies.jar 
...
Calling main(String[]) method
com.sun.javafx.fxml.ModuleHelper : <clinit>
getModuleMethod = public java.lang.Module java.lang.Class.getModule()
getResourceAsStreamMethod = public java.io.InputStream java.lang.Module.getResourceAsStream(java.lang.String) throws java.io.IOException
thisModule = module javafx.fxml
methodModule = module javafx.graphics
m = public javafx.collections.ObservableList javafx.scene.layout.Pane.getChildren()
thisModule = module javafx.fxml
methodModule = module javafx.controls
m = public final void javafx.scene.control.Labeled.setText(java.lang.String)
thisModule = module javafx.fxml
methodModule = module javafx.controls
m = public final void javafx.scene.control.Labeled.setText(java.lang.String)
thisModule = module javafx.fxml
methodModule = module javafx.controls
m = public final void javafx.scene.control.Labeled.setText(java.lang.String)
thisModule = module javafx.fxml
methodModule = module javafx.graphics
m = public final void javafx.scene.layout.VBox.setSpacing(double)

The javafx.fxml module calls only javafx.graphics and javafx.controls modules in my simple project. The substitutor can be updated to allow to call methods only from these modules or even concrete methods.

danielchemko commented 1 year ago

Thanks @AlexanderScherbatiy, your workaround worked perfectly!

danielchemko commented 1 year ago

I imagine you've already done something like the same, but my next adventure was to catch all reflective calls to @FXML to get automatically added without needing to manually launch the code-path for each reflective call via the agent. This is why I wrote as a quick-fix:

import javafx.fxml.FXML;
import org.graalvm.nativeimage.hosted.Feature;
import org.graalvm.nativeimage.hosted.RuntimeReflection;

import java.util.Arrays;

class PreComputeFieldFeature implements Feature {

    @Override
    public void beforeAnalysis(BeforeAnalysisAccess access) {
        access.registerSubtypeReachabilityHandler(this::iterateFields, Object.class);
    }

    // This method is invoked for every type that is reachable.
    private void iterateFields(DuringAnalysisAccess access, Class<?> subtype) {
        try {
            Arrays.stream(subtype.getFields()).forEach(f -> {
                if (f.getAnnotation(FXML.class) != null) {
                    System.out.println("Marking field accessed: [" + f.getDeclaringClass().getName() + "." + f.getName() + "]");
                    RuntimeReflection.register(f);
                }
            });

            Arrays.stream(subtype.getDeclaredMethods()).forEach(m -> {
                if (m.getAnnotation(FXML.class) != null) {
                    System.out.println("Marking method reflective: [" + m.getDeclaringClass().getName() + "." + m.getName() + "]");
                    RuntimeReflection.register(m);
                }
            });
        } catch (NoClassDefFoundError ex) {
        }
    }
}

Then add --feature=package.to.PreComputeFieldFeature (or just add it to a jar META-INF)

AlexanderScherbatiy commented 1 year ago

I had very simple FXML example where I always needed to press the button to add the used @FXML method into the reflect-config.json file by the tracing agent.

Your PreComputeFieldFeature is indeed very helpful to automatically register @FXML fields and methods without manually touching all available paths.

petermz commented 11 months ago

This bug has been fixed in NIK 23.0.1. The workaround should no longer be needed.