From c5d9bf5360b65dd1c349d036e69f3241af9f1a4c Mon Sep 17 00:00:00 2001 From: Almas Baim Date: Thu, 26 Dec 2024 12:53:58 +0000 Subject: [PATCH] clean up ffc impl, added initial tests --- .../core/reflect/ForeignFunctionCaller.java | 57 ++++++++------- .../fxgl/core/util/ResourceExtractor.kt | 37 ++++++++++ .../core/reflect/ForeignFunctionCallerTest.kt | 65 ++++++++++++++++++ .../fxgl/core/util/ResourceExtractorTest.kt | 45 ++++++++++++ .../nativeLibs/windows64/native-lib-test.dll | Bin 0 -> 60928 bytes 5 files changed, 180 insertions(+), 24 deletions(-) create mode 100644 fxgl-core/src/test/kotlin/com/almasb/fxgl/core/reflect/ForeignFunctionCallerTest.kt create mode 100644 fxgl-core/src/test/kotlin/com/almasb/fxgl/core/util/ResourceExtractorTest.kt create mode 100644 fxgl-core/src/test/resources/nativeLibs/windows64/native-lib-test.dll diff --git a/fxgl-core/src/main/java/com/almasb/fxgl/core/reflect/ForeignFunctionCaller.java b/fxgl-core/src/main/java/com/almasb/fxgl/core/reflect/ForeignFunctionCaller.java index 6937c9eb8b..b4c558488f 100644 --- a/fxgl-core/src/main/java/com/almasb/fxgl/core/reflect/ForeignFunctionCaller.java +++ b/fxgl-core/src/main/java/com/almasb/fxgl/core/reflect/ForeignFunctionCaller.java @@ -5,6 +5,7 @@ */ package com.almasb.fxgl.core.reflect; +import com.almasb.fxgl.core.util.EmptyRunnable; import com.almasb.fxgl.logging.Logger; import java.lang.foreign.*; @@ -36,8 +37,6 @@ public final class ForeignFunctionCaller { private static final AtomicInteger threadCount = new AtomicInteger(0); private List libraries; - private Arena arena; - private Linker linker; private List lookups = new ArrayList<>(); private Map functionsAddresses = new HashMap<>(); private Map functions = new HashMap<>(); @@ -46,7 +45,10 @@ public final class ForeignFunctionCaller { public BlockingQueue> executionQueue = new ArrayBlockingQueue<>(1000); private AtomicBoolean isRunning = new AtomicBoolean(true); - private FFCThread thread; + private Thread thread; + + private Runnable onLoaded = EmptyRunnable.INSTANCE; + private Runnable onUnloaded = EmptyRunnable.INSTANCE; private boolean isLoaded = false; /** @@ -58,6 +60,14 @@ public ForeignFunctionCaller(List libraries) { this.libraries = new ArrayList<>(libraries); } + public void setOnLoaded(Runnable onLoaded) { + this.onLoaded = onLoaded; + } + + public void setOnUnloaded(Runnable onUnloaded) { + this.onUnloaded = onUnloaded; + } + public void load() { if (isLoaded) { log.warning("Already loaded: " + libraries); @@ -66,7 +76,8 @@ public void load() { isLoaded = true; - thread = new FFCThread(this::threadTask); + thread = new Thread(this::threadTask, "FFCThread-" + threadCount.getAndIncrement()); + thread.setDaemon(true); thread.start(); // TODO: wait until libs are loaded and loop entered @@ -76,18 +87,16 @@ public void load() { private void threadTask() { log.debug("Starting native setup task"); - try (var a = Arena.ofConfined()) { - arena = a; - linker = Linker.nativeLinker(); - + try (var arena = Arena.ofConfined()) { libraries.forEach(file -> { var lookup = SymbolLookup.libraryLookup(file, arena); lookups.add(lookup); }); - context = new ForeignFunctionContext(arena, linker, lookups); + context = new ForeignFunctionContext(arena, Linker.nativeLinker(), lookups); log.debug("Native libs loaded and context created"); + onLoaded.run(); while (isRunning.get()) { try { @@ -101,6 +110,11 @@ private void threadTask() { } catch (Throwable e) { log.warning("FFCThread task failed", e); } + + // the libraries loaded iva SymbolLookup are unloaded + // when the associated arena is closed, i.e. when above try-catch completes + // however, in practice, it appears there is a delay before the lib is fully closed + onUnloaded.run(); } private MethodHandle getFunctionImpl(String name, FunctionDescriptor fd) { @@ -125,7 +139,7 @@ private MethodHandle getFunctionImpl(String name, FunctionDescriptor fd) { functionsAddresses.put(name, functionAddress); } - MethodHandle function = linker.downcallHandle(functionAddress, fd); + MethodHandle function = context.linker.downcallHandle(functionAddress, fd); functions.put(functionID, function); @@ -160,20 +174,21 @@ public void execute(Consumer functionCall) { } public void unload() { - // TODO: isLoaded = false? - // TODO: if not loaded ignore, same for overload below - isRunning.set(false); - - // TODO: execute poison pill to shutdown thread + unload(_ -> {}); } /** - * @param libExitFunctionCall the last function to call in the loaded library(-ies) + * @param libExitFunctionCall the last function to call in the loaded library(-ies), + * do not schedule any other execute() operations within the call */ public void unload(Consumer libExitFunctionCall) { - isRunning.set(false); + // TODO: isLoaded = false? + // TODO: if not loaded ignore? - execute(libExitFunctionCall); + execute(context -> { + libExitFunctionCall.accept(context); + isRunning.set(false); + }); } public final class ForeignFunctionContext { @@ -220,10 +235,4 @@ public MemorySegment allocateCharArrayFrom(String s) { return arena.allocateFrom(s); } } - - private static class FFCThread extends Thread { - FFCThread(Runnable task) { - super(task, "FFCThread-" + threadCount.getAndIncrement()); - } - } } diff --git a/fxgl-core/src/main/kotlin/com/almasb/fxgl/core/util/ResourceExtractor.kt b/fxgl-core/src/main/kotlin/com/almasb/fxgl/core/util/ResourceExtractor.kt index 06d046c4d6..4ce0e8046c 100644 --- a/fxgl-core/src/main/kotlin/com/almasb/fxgl/core/util/ResourceExtractor.kt +++ b/fxgl-core/src/main/kotlin/com/almasb/fxgl/core/util/ResourceExtractor.kt @@ -6,9 +6,11 @@ package com.almasb.fxgl.core.util +import com.almasb.fxgl.core.util.Platform.* import com.almasb.fxgl.logging.Logger import java.net.URL import java.nio.file.Files +import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardCopyOption @@ -23,6 +25,12 @@ class ResourceExtractor { private val log = Logger.get(ResourceExtractor::class.java) + private val nativeLibResourceDirNames = mapOf( + WINDOWS to "windows64", + LINUX to "linux64", + MAC to "mac64" + ) + /** * Extracts the file at jar [url] as a [relativeFilePath]. * Note: the destination file will be overwritten. @@ -52,5 +60,34 @@ class ResourceExtractor { return file.toUri().toURL() } + + /** + * Extracts the file at jar nativeLibs/platformDirName/[libName]. + * Note: the destination file will be overwritten. + * + * @return the path on the local file system to the extracted file + */ + @JvmStatic fun extractNativeLibAsPath(libName: String): Path { + return Paths.get(extractNativeLib(libName).toURI()) + } + + /** + * Extracts the file at jar nativeLibs/platformDirName/[libName]. + * Note: the destination file will be overwritten. + * + * @return the url on the local file system of the extracted file + */ + @JvmStatic fun extractNativeLib(libName: String): URL { + val platform = Platform.get() + + if (nativeLibResourceDirNames.containsKey(platform)) { + val dirName = nativeLibResourceDirNames[platform] + + return extract(javaClass.getResource("/nativeLibs/$dirName/$libName"), libName) + + } else { + throw RuntimeException("FXGL does not have libraries for this platform: $platform") + } + } } } \ No newline at end of file diff --git a/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/reflect/ForeignFunctionCallerTest.kt b/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/reflect/ForeignFunctionCallerTest.kt new file mode 100644 index 0000000000..23b9aef35d --- /dev/null +++ b/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/reflect/ForeignFunctionCallerTest.kt @@ -0,0 +1,65 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ + +package com.almasb.fxgl.core.reflect + +import com.almasb.fxgl.core.util.ResourceExtractor +import org.hamcrest.CoreMatchers +import org.hamcrest.CoreMatchers.* +import org.hamcrest.MatcherAssert +import org.hamcrest.MatcherAssert.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import java.lang.foreign.FunctionDescriptor +import java.lang.foreign.ValueLayout +import java.nio.file.Files +import java.util.concurrent.CountDownLatch + +/** + * @author Almas Baim (https://github.com/AlmasB) + */ +class ForeignFunctionCallerTest { + + @EnabledOnOs(OS.WINDOWS) + @Test + @EnabledIfEnvironmentVariable(named = "CI", matches = "true") + fun `Downcall a native function`() { + val file = ResourceExtractor.extractNativeLibAsPath("native-lib-test.dll") + + val countDown = CountDownLatch(1) + + val ffc = ForeignFunctionCaller(listOf(file)) + ffc.setOnLoaded { + countDown.countDown() + } + + ffc.load() + + ffc.execute { + val result = it.call( + "testDownCall", + FunctionDescriptor.of( + ValueLayout.JAVA_INT, + ValueLayout.JAVA_INT + ), + 5 + ) as Int + + assertThat(result, `is`(25)) + } + + ffc.unload() + + countDown.await() + + // block some time until the native lib is fully unloaded + Thread.sleep(2500) + + Files.deleteIfExists(file) + } +} \ No newline at end of file diff --git a/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/util/ResourceExtractorTest.kt b/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/util/ResourceExtractorTest.kt new file mode 100644 index 0000000000..17ecd05300 --- /dev/null +++ b/fxgl-core/src/test/kotlin/com/almasb/fxgl/core/util/ResourceExtractorTest.kt @@ -0,0 +1,45 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ + +package com.almasb.fxgl.core.util + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import java.nio.file.Files +import java.nio.file.Paths + +/** + * @author Almas Baim (https://github.com/AlmasB) + */ +class ResourceExtractorTest { + + @Test + @EnabledIfEnvironmentVariable(named = "CI", matches = "true") + fun `File is correctly extracted from resources`() { + val testFile = Paths.get(System.getProperty("user.home")) + .resolve(".openjfx") + .resolve("cache") + .resolve("fxgl-21") + .resolve("test_file.txt") + + Files.deleteIfExists(testFile) + + assertTrue(Files.notExists(testFile)) + + val file = Paths.get( + ResourceExtractor.extract(javaClass.getResource("/com/almasb/fxgl/localization/LocalEnglish.properties"), "test_file.txt").toURI() + ) + + val s = Files.readString(file) + + Files.deleteIfExists(file) + + assertThat(s, `is`("data.key = Data2")) + } +} \ No newline at end of file diff --git a/fxgl-core/src/test/resources/nativeLibs/windows64/native-lib-test.dll b/fxgl-core/src/test/resources/nativeLibs/windows64/native-lib-test.dll new file mode 100644 index 0000000000000000000000000000000000000000..435c6ef7f07172289d7375299d641f1c2b092013 GIT binary patch literal 60928 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 literal 0 HcmV?d00001