You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/09/09 03:04:42 UTC

(groovy) 02/02: GROOVY-12380: native: ship the async runtime's reachability metadata with groovy-concurrent-java

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit f803a6ad407422851e6d98c04a2438cfeebb2cfe
Author: Paul King <pa...@asert.com.au>
AuthorDate: Wed Sep 9 11:55:33 2026 +1000

    GROOVY-12380: native: ship the async runtime's reachability metadata with groovy-concurrent-java
    
    groovy-concurrent-java repackages the async runtime classes without the
    groovy jar, whose metadata carries the family registering the virtual
    thread executor and ScopedValue lookups those classes perform, so a
    pure-Java image had no copy. Generate the same family into the module
    jar from the groovy jar's generator, and check in the module's own tests
    that the file is present and names only classes the jar contains.
---
 .../groovy/tools/NativeImageMetadataGenerator.java |  3 ++
 src/spec/doc/invokedynamic-support.adoc            |  5 ++--
 .../groovy/tools/NativeImageMetadataTest.groovy    | 15 ++++++++++
 subprojects/groovy-concurrent-java/build.gradle    | 16 +++++++++++
 .../concurrent/java/ModuleSelfContainmentTest.java | 33 ++++++++++++++++++++++
 5 files changed, 70 insertions(+), 2 deletions(-)

diff --git a/src/main/java/org/codehaus/groovy/tools/NativeImageMetadataGenerator.java b/src/main/java/org/codehaus/groovy/tools/NativeImageMetadataGenerator.java
index b1c0645d8c..c0b084b44d 100644
--- a/src/main/java/org/codehaus/groovy/tools/NativeImageMetadataGenerator.java
+++ b/src/main/java/org/codehaus/groovy/tools/NativeImageMetadataGenerator.java
@@ -329,6 +329,9 @@ public class NativeImageMetadataGenerator {
         for (Class<?> extension : instanceExtensions) generator.scannedHolder(registry, extension);
         for (Class<?> extension : staticExtensions) generator.scannedHolder(registry, extension);
         if ("groovy-xml".equals(artifactId)) generator.jaxpFactories();
+        // the Java-only concurrency jar repackages the async runtime without the groovy jar,
+        // whose metadata carries this family, so a pure-Java image needs its own copy (GROOVY-12380)
+        if ("groovy-concurrent-java".equals(artifactId)) generator.asyncRuntime();
         return generator.toJson();
     }
 
diff --git a/src/spec/doc/invokedynamic-support.adoc b/src/spec/doc/invokedynamic-support.adoc
index 7b6d89cc0e..ed9cbbb373 100644
--- a/src/spec/doc/invokedynamic-support.adoc
+++ b/src/spec/doc/invokedynamic-support.adoc
@@ -277,8 +277,9 @@ name and scans, and the types in their signatures. `groovy-xml` adds what `Facto
 every XML user: the JAXP factory lookups, a `ServiceLoader` resource read that an exact-mode image
 must allow even though the JDK ships no such file, and the JDK's default implementations it then
 instantiates by name. `groovy-json` ships only the `ServiceLoader`-loaded string service its parser
-uses. Modules that reflect over nothing but the application's data, `groovy-http-builder` among
-them, need none.
+uses. `groovy-concurrent-java`, which repackages the async runtime for use without the groovy jar,
+carries that runtime's entries so a pure-Java image gets them too. Modules that reflect over nothing
+but the application's data, `groovy-http-builder` among them, need none.
 
 Every entry is conditional on the class that performs the access being reached, so an image pays
 only for what it uses; a statically compiled program that never links a dynamic call site carries
diff --git a/src/test/groovy/org/codehaus/groovy/tools/NativeImageMetadataTest.groovy b/src/test/groovy/org/codehaus/groovy/tools/NativeImageMetadataTest.groovy
index ab1e024775..cfb4081ee1 100644
--- a/src/test/groovy/org/codehaus/groovy/tools/NativeImageMetadataTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/tools/NativeImageMetadataTest.groovy
@@ -267,6 +267,21 @@ final class NativeImageMetadataTest {
         assert !(new JsonSlurper().parseText(NativeImageMetadataGenerator.generateForModule('groovy-nio', [], [])) as Map).containsKey('resources')
     }
 
+    @Test
+    void concurrentJavaModuleMetadataCarriesTheAsyncRuntimeFamily() {
+        // GROOVY-12380: the Java-only jar replaces the groovy jar, whose metadata carries this family
+        def entries = (new JsonSlurper().parseText(NativeImageMetadataGenerator.generateForModule('groovy-concurrent-java', [], [])) as Map).reflection as List<Map>
+        def executors = entries.findAll { it.type == 'java.util.concurrent.Executors' }
+        assert executors*.condition*.typeReached as Set == ['org.apache.groovy.runtime.async.AsyncExecutors', 'org.apache.groovy.runtime.async.DefaultPool'] as Set
+        assert executors.every { it.methods*.name == ['newVirtualThreadPerTaskExecutor'] }
+        def scopedValue = entries.find { it.type == 'java.lang.ScopedValue' }
+        assert scopedValue.condition.typeReached == 'org.apache.groovy.runtime.async.ScopedLocal'
+        assert scopedValue.methods*.name as Set == ['newInstance', 'get', 'isBound', 'where'] as Set
+        assert entries.find { it.type == 'java.lang.ScopedValue$Carrier' }?.methods*.name == ['run']
+        // nothing that needs the groovy jar: no registry family
+        assert !entries.any { it.condition.typeReached == REGISTRY }
+    }
+
     @Test
     void entriesAreUnique() {
         def keys = reflection().collect { [it.condition.typeReached, it.type] }
diff --git a/subprojects/groovy-concurrent-java/build.gradle b/subprojects/groovy-concurrent-java/build.gradle
index bad80c9035..e5fc31d2e9 100644
--- a/subprojects/groovy-concurrent-java/build.gradle
+++ b/subprojects/groovy-concurrent-java/build.gradle
@@ -66,6 +66,22 @@ tasks.named('jar') {
     }
 }
 
+// The async runtime's GraalVM reachability metadata (GROOVY-12380): the groovy jar
+// ships it for Groovy users, but this jar replaces the groovy jar, so pure-Java
+// images need their own copy. Generated by the same generator, from the groovy jar.
+def nativeImageMetadataDir = layout.buildDirectory.dir('native-image-metadata')
+def nativeImageMetadata = tasks.register('nativeImageMetadata', JavaExec) {
+    description = 'Generates the GraalVM reachability metadata for the async runtime this jar repackages'
+    dependsOn rootProject.tasks.named('repackageJar')
+    mainClass = 'org.codehaus.groovy.tools.NativeImageMetadataGenerator'
+    classpath = files(rootProject.tasks.named('repackageJar').flatMap { it.archiveFile })
+    outputs.dir(nativeImageMetadataDir)
+    args(project.name, nativeImageMetadataDir.get().asFile.absolutePath, '', '')
+}
+tasks.named('jar') {
+    from(nativeImageMetadata)
+}
+
 groovyLibrary {
     optionalModule()
     withoutBinaryCompatibilityChecks()
diff --git a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ModuleSelfContainmentTest.java b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ModuleSelfContainmentTest.java
index 0c07b8a909..11cc5ce784 100644
--- a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ModuleSelfContainmentTest.java
+++ b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ModuleSelfContainmentTest.java
@@ -28,6 +28,7 @@ import java.io.InputStream;
 import java.net.URI;
 import java.net.URL;
 import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
@@ -118,6 +119,38 @@ class ModuleSelfContainmentTest {
         assertTrue(failures.isEmpty(), () -> "classes that cannot be reflected over without Groovy core: " + failures);
     }
 
+    /**
+     * GROOVY-12380: the jar ships the async runtime's GraalVM reachability metadata,
+     * since it replaces the groovy jar that otherwise carries it, and every Groovy
+     * class that metadata names is inside this jar.
+     */
+    @Test
+    void shipsReachabilityMetadataForTheClassesItContains() throws IOException {
+        String path = "META-INF/native-image/org.apache.groovy/groovy-concurrent-java/reachability-metadata.json";
+        try (JarFile jar = new JarFile(moduleJar().toFile())) {
+            JarEntry entry = jar.getJarEntry(path);
+            assertTrue(entry != null, path + " is missing from the jar");
+            String json;
+            try (InputStream in = jar.getInputStream(entry)) {
+                json = new String(in.readAllBytes(), StandardCharsets.UTF_8);
+            }
+            Set<String> named = new TreeSet<>();
+            Matcher m = Pattern.compile("\"(?:typeReached|type)\": \"([^\"]+)\"").matcher(json);
+            while (m.find()) named.add(m.group(1));
+            assertTrue(named.contains("org.apache.groovy.runtime.async.DefaultPool"), "async family missing: " + named);
+            assertTrue(named.contains("org.apache.groovy.runtime.async.ScopedLocal"), "async family missing: " + named);
+            assertTrue(named.contains("java.util.concurrent.Executors"), "async family missing: " + named);
+            List<String> absent = new ArrayList<>();
+            for (String type : named) {
+                if ((type.startsWith("groovy.") || type.startsWith("org.apache.groovy.") || type.startsWith("org.codehaus.groovy."))
+                        && jar.getJarEntry(type.replace('.', '/') + ".class") == null) {
+                    absent.add(type);
+                }
+            }
+            assertTrue(absent.isEmpty(), "metadata names Groovy classes this jar does not contain: " + absent);
+        }
+    }
+
     private static Path moduleJar() {
         URL location = ChannelSelect.class.getProtectionDomain().getCodeSource().getLocation();
         Path path = Paths.get(URI.create(location.toString()));