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


The following commit(s) were added to refs/heads/master by this push:
     new db97f3f644 some smoke tests for groovy-concurrent-java
db97f3f644 is described below

commit db97f3f644bba700c8dd4183d2cc2d468c8e2e60
Author: Paul King <[email protected]>
AuthorDate: Mon Sep 7 08:56:03 2026 +1000

    some smoke tests for groovy-concurrent-java
---
 subprojects/groovy-concurrent-java/build.gradle    |  67 +++++-
 .../apache/groovy/concurrent/java/ActorTest.java   |  92 ++++++++
 .../apache/groovy/concurrent/java/AgentTest.java   |  42 ++++
 .../groovy/concurrent/java/ChannelSelectTest.java  | 256 +++++++++++++++++++++
 .../groovy/concurrent/java/DataflowTest.java       |  54 +++++
 .../concurrent/java/ModuleSelfContainmentTest.java | 173 ++++++++++++++
 .../apache/groovy/concurrent/java/PoolTest.java    |  63 +++++
 .../groovy/concurrent/java/package-info.java       |  23 ++
 8 files changed, 762 insertions(+), 8 deletions(-)

diff --git a/subprojects/groovy-concurrent-java/build.gradle 
b/subprojects/groovy-concurrent-java/build.gradle
index af5e22a213..bad80c9035 100644
--- a/subprojects/groovy-concurrent-java/build.gradle
+++ b/subprojects/groovy-concurrent-java/build.gradle
@@ -21,13 +21,12 @@ plugins {
     id 'org.apache.groovy-library'
 }
 
-// This module has no source code of its own. Its jar is extracted from
+// This module has no main source code of its own. Its jar is extracted from
 // the Groovy core jar, containing only the pure-Java concurrent API.
 // Java users can depend on this module without the Groovy runtime.
 sourceSets {
     main.java.srcDirs = []
     main.groovy.srcDirs = []
-    test.java.srcDirs = []
     test.groovy.srcDirs = []
 }
 
@@ -35,15 +34,32 @@ sourceSets {
 configurations.groovyCompilerClasspath.dependencies.clear()
 
 // The jar is built by filtering core's repackageJar (shadow) output
+def apiIncludes = [
+    'groovy/concurrent/**',
+    'org/apache/groovy/runtime/async/**',
+    'org/apache/groovy/util/concurrent/ThreadHelper*',
+    // Types the API above references that live outside those packages.
+    // ModuleSelfContainmentTest fails the build if a groovy.* reference
+    // is left unresolved by this list.
+    'groovy/transform/Internal*',
+    'groovy/util/function/TriConsumer*',
+    'org/apache/groovy/lang/annotation/GroovyABI*',
+]
+// Groovy-dependent classes
+def apiExcludes = [
+    '**/AsyncClosureUtils*',
+    '**/Dataflows*',
+]
+
 tasks.named('jar') {
     dependsOn rootProject.tasks.named('repackageJar')
+    // Copy-spec patterns are not tracked as task inputs on their own, so an
+    // edited list would otherwise leave a stale, up-to-date jar behind.
+    inputs.property('apiIncludes', apiIncludes)
+    inputs.property('apiExcludes', apiExcludes)
     from(zipTree(rootProject.tasks.named('repackageJar').flatMap { 
it.archiveFile })) {
-        include 'groovy/concurrent/**'
-        include 'org/apache/groovy/runtime/async/**'
-        include 'org/apache/groovy/util/concurrent/ThreadHelper*'
-        // Exclude Groovy-dependent classes
-        exclude '**/AsyncClosureUtils*'
-        exclude '**/Dataflows*'
+        include apiIncludes
+        exclude apiExcludes
     }
     manifest {
         attributes 'Automatic-Module-Name': 'org.apache.groovy.concurrent.java'
@@ -55,6 +71,41 @@ groovyLibrary {
     withoutBinaryCompatibilityChecks()
 }
 
+// The tests are the pure-Java consumer's view of this module: they are 
compiled
+// and run against the module jar plus JUnit only, exactly as a Java project
+// depending on org.apache.groovy:groovy-concurrent-java would be. Groovy core
+// (which the conventions put on testImplementation) is deliberately kept off
+// the classpath, so a groovy.* type leaking into a signature fails here rather
+// than at a user's site.
+configurations {
+    javaConsumerTest {
+        canBeConsumed = false
+        description = 'Test-only dependencies of the pure-Java consumer tests'
+    }
+}
+
+dependencies {
+    javaConsumerTest "org.junit.jupiter:junit-jupiter:${versions.junit6}"
+    // the JUnit jars carry @API annotations; keep javac quiet about them
+    javaConsumerTest "org.apiguardian:apiguardian-api:${versions.apiguardian}"
+    javaConsumerTest 
"org.junit.platform:junit-platform-launcher:${versions.junit6}", {
+        exclude group: 'org.apiguardian', module: 'apiguardian-api'
+    }
+}
+
+def moduleJar = tasks.named('repackageJar') // the published artifact, not the 
raw intermediate
+sourceSets {
+    test {
+        compileClasspath = files(moduleJar) + configurations.javaConsumerTest
+        runtimeClasspath = output + files(moduleJar) + 
configurations.javaConsumerTest
+    }
+}
+
+tasks.named('test') {
+    classpath = sourceSets.test.runtimeClasspath
+    testClassesDirs = sourceSets.test.output.classesDirs
+}
+
 // Declare capability for mutual exclusion with Groovy core on library 
variants.
 afterEvaluate {
     def capabilityVersion = sharedConfiguration.groovyVersion.get()
diff --git 
a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ActorTest.java
 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ActorTest.java
new file mode 100644
index 0000000000..35ff351289
--- /dev/null
+++ 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ActorTest.java
@@ -0,0 +1,92 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.concurrent.java;
+
+import groovy.concurrent.Actor;
+import groovy.concurrent.Awaitable;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.apache.groovy.runtime.async.AsyncSupport.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class ActorTest {
+
+    @Test
+    void reactorProcessesMessages() {
+        Actor<Integer> doubler = Actor.<Integer, Integer>reactor(n -> n * 2);
+        try {
+            int result = await(doubler.<Integer>sendAndGet(21));
+            assertEquals(42, result);
+        } finally {
+            doubler.stop();
+        }
+    }
+
+    @Test
+    void statefulActorMaintainsState() {
+        Actor<String> counter = Actor.<String, Integer>stateful(0, (state, 
msg) -> {
+            if ("increment".equals(msg)) return state + 1;
+            return state;
+        });
+        try {
+            counter.send("increment");
+            counter.send("increment");
+            int result = await(counter.<Integer>sendAndGet("increment"));
+            assertEquals(3, result);
+        } finally {
+            counter.stop();
+        }
+    }
+
+    @Test
+    void errorHandlerSeesTheFailure() {
+        AtomicReference<Throwable> captured = new AtomicReference<>();
+        Actor<String> actor = Actor.<String, String>reactor(msg -> {
+            if ("boom".equals(msg)) throw new IllegalStateException("boom");
+            return msg;
+        }).onError((Throwable t, String msg) -> captured.set(t));
+        try {
+            assertEquals("ok", await(actor.<String>sendAndGet("ok")));
+            assertThrows(IllegalStateException.class, () -> 
await(actor.<String>sendAndGet("boom")));
+            for (int i = 0; i < 100 && captured.get() == null; i++) {
+                await(Awaitable.delay(10));
+            }
+            assertEquals("boom", captured.get().getMessage());
+        } finally {
+            actor.stop();
+        }
+    }
+
+    @Test
+    void contextAwareErrorHandlerCanStopTheActor() {
+        Actor<String> actor = Actor.<String, String>reactor(msg -> {
+            throw new IllegalStateException("always");
+        }).onError((ctx, t, msg) -> ctx.self().stop());
+
+        actor.send("trigger");
+        for (int i = 0; i < 100 && actor.isActive(); i++) {
+            await(Awaitable.delay(10));
+        }
+        assertFalse(actor.isActive());
+    }
+}
diff --git 
a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/AgentTest.java
 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/AgentTest.java
new file mode 100644
index 0000000000..602bd273bc
--- /dev/null
+++ 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/AgentTest.java
@@ -0,0 +1,42 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.concurrent.java;
+
+import groovy.concurrent.Agent;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.groovy.runtime.async.AsyncSupport.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class AgentTest {
+
+    @Test
+    void agentSerialisesUpdates() {
+        Agent<Integer> counter = Agent.create(0);
+        try {
+            counter.send(n -> n + 1);
+            counter.send(n -> n + 1);
+            counter.send(n -> n + 1);
+            int result = await(counter.getAsync());
+            assertEquals(3, result);
+        } finally {
+            counter.shutdown();
+        }
+    }
+}
diff --git 
a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ChannelSelectTest.java
 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ChannelSelectTest.java
new file mode 100644
index 0000000000..7842f4dd89
--- /dev/null
+++ 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ChannelSelectTest.java
@@ -0,0 +1,256 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.concurrent.java;
+
+import groovy.concurrent.AsyncChannel;
+import groovy.concurrent.Awaitable;
+import groovy.concurrent.ChannelSelect;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static groovy.concurrent.ChannelSelect.after;
+import static groovy.concurrent.ChannelSelect.offers;
+import static groovy.concurrent.ChannelSelect.receive;
+import static groovy.concurrent.ChannelSelect.send;
+import static org.apache.groovy.runtime.async.AsyncSupport.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link ChannelSelect} driven from plain Java: choice policies, mixed
+ * send/receive offers, guards, preconditions and timer branches.
+ */
+class ChannelSelectTest {
+
+    @Test
+    void selectTakesTheFirstChannelToDeliver() {
+        AsyncChannel<String> ch1 = AsyncChannel.create(10);
+        AsyncChannel<String> ch2 = AsyncChannel.create(10);
+        ChannelSelect sel = ChannelSelect.from(ch1, ch2);
+
+        Awaitable.go(() -> {
+            await(Awaitable.delay(50));
+            return await(ch1.send("from-ch1"));
+        });
+
+        ChannelSelect.Result result = await(sel.select());
+        assertEquals(0, result.getIndex());
+        assertSame(ch1, result.getChannel());
+        String value = result.getValue();
+        assertEquals("from-ch1", value);
+        assertFalse(result.isSend());
+        assertFalse(result.isTimeout());
+    }
+
+    @Test
+    void losingBranchesAreNotConsumed() {
+        AsyncChannel<String> a = AsyncChannel.create(4);
+        AsyncChannel<String> b = AsyncChannel.create(4);
+        await(a.send("a1"));
+        await(b.send("b1"));
+
+        ChannelSelect.Result result = await(ChannelSelect.from(a, b).select());
+        assertEquals(0, result.getIndex());
+        assertEquals("a1", result.<String>getValue());
+
+        assertEquals(1, b.getBufferedSize());
+        assertEquals("b1", await(b.receive()));
+    }
+
+    @Test
+    void fairChoiceRotatesAmongReadyChannels() {
+        AsyncChannel<String> a = AsyncChannel.create(4);
+        AsyncChannel<String> b = AsyncChannel.create(4);
+        AsyncChannel<String> c = AsyncChannel.create(4);
+        for (int i = 0; i < 2; i++) {
+            await(a.send("a" + i));
+            await(b.send("b" + i));
+            await(c.send("c" + i));
+        }
+
+        ChannelSelect fair = ChannelSelect.from(a, b, c).fair();
+        List<Integer> order = new ArrayList<>();
+        for (int i = 0; i < 6; i++) {
+            order.add(await(fair.select()).getIndex());
+        }
+        assertEquals(List.of(0, 1, 2, 0, 1, 2), order);
+
+        // the default policy is priority by list order
+        AsyncChannel<String> p = AsyncChannel.create(4);
+        AsyncChannel<String> q = AsyncChannel.create(4);
+        await(p.send("p0"));
+        await(p.send("p1"));
+        await(q.send("q0"));
+        ChannelSelect priority = ChannelSelect.from(p, q);
+        assertEquals(0, await(priority.select()).getIndex());
+        assertEquals(0, await(priority.select()).getIndex());
+    }
+
+    @Test
+    void randomChoiceSpreadsAmongReadyChannelsAndKeepsEachInOrder() {
+        int rounds = 200;
+        AsyncChannel<Integer> a = AsyncChannel.create(rounds);
+        AsyncChannel<Integer> b = AsyncChannel.create(rounds);
+        for (int i = 0; i < rounds; i++) {
+            await(a.send(i));
+            await(b.send(i));
+        }
+
+        ChannelSelect random = ChannelSelect.from(a, b).random();
+        List<List<Integer>> taken = List.of(new ArrayList<>(), new 
ArrayList<>());
+        for (int i = 0; i < rounds; i++) {
+            ChannelSelect.Result result = await(random.select());
+            taken.get(result.getIndex()).add(result.getValue());
+        }
+        // the chance of one channel never being chosen in 200 draws is 2^-199
+        assertFalse(taken.get(0).isEmpty());
+        assertFalse(taken.get(1).isEmpty());
+        for (List<Integer> values : taken) {
+            for (int i = 0; i < values.size(); i++) {
+                assertEquals(i, values.get(i));
+            }
+        }
+    }
+
+    @Test
+    void sendOfferCommitsWhenAReceiverIsWaiting() {
+        AsyncChannel<String> out = AsyncChannel.create(); // rendezvous
+        AsyncChannel<String> other = AsyncChannel.create(4);
+
+        Awaitable<String> taken = out.receive(); // a receiver is already 
waiting
+        ChannelSelect.Result result = await(offers(send(out, "opener"), 
receive(other)).select());
+
+        assertEquals(0, result.getIndex());
+        assertTrue(result.isSend());
+        assertEquals("opener", result.<String>getValue());
+        assertEquals("opener", await(taken));
+    }
+
+    @Test
+    void sendOfferCommitsIntoFreeBufferSpace() {
+        AsyncChannel<String> out = AsyncChannel.create(1);
+        AsyncChannel<String> other = AsyncChannel.create(4);
+
+        ChannelSelect.Result result = await(offers(send(out, "buffered"), 
receive(other)).select());
+        assertEquals(0, result.getIndex());
+        assertTrue(result.isSend());
+        assertEquals(1, out.getBufferedSize());
+        assertEquals("buffered", await(out.receive()));
+    }
+
+    @Test
+    void guardMasksAnOfferWithoutRenumberingTheOthers() {
+        AsyncChannel<String> first = AsyncChannel.create(4);
+        AsyncChannel<String> second = AsyncChannel.create(4);
+        await(first.send("f1"));
+        await(second.send("s1"));
+
+        AtomicBoolean gate = new AtomicBoolean(false);
+        ChannelSelect sel = offers(receive(first).when(gate::get), 
receive(second));
+
+        // both are ready, but the first branch is guarded off: the second
+        // wins and still calls itself index 1
+        ChannelSelect.Result result = await(sel.select());
+        assertEquals(1, result.getIndex());
+        assertEquals("s1", result.<String>getValue());
+        assertEquals(1, first.getBufferedSize());
+
+        // the guard is consulted afresh on every select
+        gate.set(true);
+        result = await(sel.select());
+        assertEquals(0, result.getIndex());
+        assertEquals("f1", result.<String>getValue());
+    }
+
+    @Test
+    void positionalPreconditionsMaskOffers() {
+        AsyncChannel<String> a = AsyncChannel.create(4);
+        AsyncChannel<String> b = AsyncChannel.create(4);
+        await(a.send("a1"));
+        await(b.send("b1"));
+        ChannelSelect sel = ChannelSelect.from(a, b);
+
+        ChannelSelect.Result result = await(sel.select(false, true));
+        assertEquals(1, result.getIndex());
+        assertEquals("b1", result.<String>getValue());
+        assertEquals(1, a.getBufferedSize());
+
+        assertThrows(IllegalArgumentException.class, () -> sel.select(true));
+        assertThrows(IllegalStateException.class, () -> 
await(sel.select(false, false)));
+        assertEquals(1, a.getBufferedSize());
+    }
+
+    @Test
+    void timerOfferWinsAQuietSelect() {
+        AsyncChannel<String> work = AsyncChannel.create(4);
+        Instant before = Instant.now();
+
+        ChannelSelect.Result result = await(offers(receive(work), 
after(50)).select());
+        assertEquals(1, result.getIndex());
+        assertTrue(result.isTimeout());
+        assertFalse(result.isSend());
+        assertNull(result.getChannel());
+        Instant firedAt = result.getValue();
+        assertFalse(firedAt.isBefore(before));
+    }
+
+    @Test
+    void dataBeatsTheTimer() {
+        AsyncChannel<String> work = AsyncChannel.create(4);
+        await(work.send("job"));
+
+        ChannelSelect.Result result = await(offers(receive(work), 
after(Duration.ofSeconds(5))).select());
+        assertEquals(0, result.getIndex());
+        assertFalse(result.isTimeout());
+        assertEquals("job", result.<String>getValue());
+    }
+
+    @Test
+    void timerChannelIsAFixedDeadlineAcrossSelects() {
+        AsyncChannel<Integer> work = AsyncChannel.create(16);
+        AsyncChannel<Instant> deadline = AsyncChannel.after(200);
+        ChannelSelect sel = ChannelSelect.from(work, deadline);
+
+        Awaitable.go(() -> {
+            for (int i = 0; i < 3; i++) {
+                await(work.send(i));
+                await(Awaitable.delay(10));
+            }
+            return null;
+        });
+
+        List<Integer> received = new ArrayList<>();
+        while (true) {
+            ChannelSelect.Result result = await(sel.select());
+            if (result.getIndex() == 1) break;
+            received.add(result.getValue());
+        }
+        assertEquals(List.of(0, 1, 2), received);
+        assertTrue(deadline.isClosed());
+    }
+}
diff --git 
a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/DataflowTest.java
 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/DataflowTest.java
new file mode 100644
index 0000000000..00eb7690a1
--- /dev/null
+++ 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/DataflowTest.java
@@ -0,0 +1,54 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.concurrent.java;
+
+import groovy.concurrent.AsyncScope;
+import groovy.concurrent.Awaitable;
+import groovy.concurrent.DataflowVariable;
+import org.apache.groovy.runtime.async.AsyncSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.groovy.runtime.async.AsyncSupport.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class DataflowTest {
+
+    @Test
+    void dataflowVariablesBindInAnyOrder() {
+        DataflowVariable<Integer> x = new DataflowVariable<>();
+        DataflowVariable<Integer> y = new DataflowVariable<>();
+
+        Awaitable<Integer> z = Awaitable.go(() -> await(x) + await(y));
+
+        AsyncSupport.getExecutor().execute(() -> x.bind(10));
+        AsyncSupport.getExecutor().execute(() -> y.bind(5));
+
+        assertEquals(15, await(z));
+    }
+
+    @Test
+    void structuredConcurrencyWithScope() {
+        int result = AsyncScope.withScope(scope -> {
+            Awaitable<Integer> a = scope.async(() -> 10);
+            Awaitable<Integer> b = scope.async(() -> 20);
+            return await(a) + await(b);
+        });
+        assertEquals(30, result);
+    }
+}
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
new file mode 100644
index 0000000000..98f0607261
--- /dev/null
+++ 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/ModuleSelfContainmentTest.java
@@ -0,0 +1,173 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.concurrent.java;
+
+import groovy.concurrent.ChannelSelect;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The module jar must stand alone: a Java project depending on it has no
+ * Groovy runtime, so every {@code groovy.*} type a class in the jar
+ * mentions, in a supertype, a signature, or an annotation, has to ship in
+ * the jar too. The jar's include list lives in this module's build script;
+ * these checks are what keep it honest.
+ */
+class ModuleSelfContainmentTest {
+
+    /** A class name in one of Groovy's own packages, as written in a class 
file. */
+    private static final String GROOVY_CLASS = 
"(?:groovy|org/apache/groovy)(?:/[a-z_]\\w*)*/[A-Z][\\w$]*";
+    private static final Pattern BARE_NAME = Pattern.compile("^" + 
GROOVY_CLASS + "$");
+    private static final Pattern DESCRIPTOR = Pattern.compile("L(" + 
GROOVY_CLASS + ")[<;]");
+
+    @Test
+    void groovyCoreIsNotOnTheTestClasspath() {
+        // the tests only prove anything if they are compiled and run the way
+        // a Java consumer is: against the module jar alone
+        assertThrows(ClassNotFoundException.class, () -> 
Class.forName("groovy.lang.GroovyObject"));
+    }
+
+    @Test
+    void everyGroovyReferenceResolvesInsideTheJar() throws IOException {
+        Map<String, Set<String>> unresolved = new TreeMap<>();
+        try (JarFile jar = new JarFile(moduleJar().toFile())) {
+            Set<String> present = jar.stream()
+                    .map(JarEntry::getName)
+                    .filter(name -> name.endsWith(".class"))
+                    .collect(Collectors.toSet());
+            for (String name : present) {
+                Set<String> missing = new TreeSet<>();
+                try (InputStream in = 
jar.getInputStream(jar.getJarEntry(name))) {
+                    for (String ref : groovyReferences(in.readAllBytes())) {
+                        if (!present.contains(ref + ".class")) 
missing.add(ref);
+                    }
+                }
+                if (!missing.isEmpty()) unresolved.put(name, missing);
+            }
+        }
+        assertTrue(unresolved.isEmpty(),
+                () -> "groovy types referenced but not shipped in the jar (add 
them to the jar's include list): " + unresolved);
+    }
+
+    @Test
+    void everyClassReflectsWithoutGroovyCore() throws Exception {
+        Path jar = moduleJar();
+        List<String> failures = new ArrayList<>();
+        try (URLClassLoader loader = new URLClassLoader(new 
URL[]{jar.toUri().toURL()}, ClassLoader.getPlatformClassLoader());
+             JarFile jarFile = new JarFile(jar.toFile())) {
+            List<String> classNames = jarFile.stream()
+                    .map(JarEntry::getName)
+                    .filter(name -> name.endsWith(".class"))
+                    .map(name -> name.substring(0, name.length() - 
".class".length()).replace('/', '.'))
+                    .collect(Collectors.toList());
+            int members = 0;
+            for (String className : classNames) {
+                try {
+                    Class<?> c = Class.forName(className, false, loader);
+                    // each of these resolves the types it mentions
+                    if (c.getSuperclass() != null) members++;
+                    members += c.getInterfaces().length
+                            + c.getDeclaredFields().length
+                            + c.getDeclaredConstructors().length
+                            + c.getDeclaredMethods().length;
+                } catch (Throwable t) {
+                    failures.add(className + ": " + t);
+                }
+            }
+            assertTrue(members > 0, "nothing was reflected over");
+        }
+        assertTrue(failures.isEmpty(), () -> "classes that cannot be reflected 
over without Groovy core: " + failures);
+    }
+
+    private static Path moduleJar() {
+        URL location = 
ChannelSelect.class.getProtectionDomain().getCodeSource().getLocation();
+        Path path = Paths.get(java.net.URI.create(location.toString()));
+        assertTrue(path.getFileName().toString().endsWith(".jar"),
+                () -> "the API should be loaded from the module jar, not from 
" + path);
+        return path;
+    }
+
+    private static final int CONSTANT_UTF8 = 1;
+    private static final int CONSTANT_LONG = 5;
+    private static final int CONSTANT_DOUBLE = 6;
+    /** Byte size of each non-Utf8 constant pool entry, by tag (JVMS ยง4.4). */
+    private static final int[] CONSTANT_SIZE = new int[21];
+
+    static {
+        for (int tag : new int[]{7, 8, 16, 19, 20}) CONSTANT_SIZE[tag] = 2; // 
Class, String, MethodType, Module, Package
+        CONSTANT_SIZE[15] = 3; // MethodHandle
+        for (int tag : new int[]{3, 4, 9, 10, 11, 12, 17, 18}) 
CONSTANT_SIZE[tag] = 4; // Integer, Float, refs, NameAndType, Dynamic, 
InvokeDynamic
+        CONSTANT_SIZE[CONSTANT_LONG] = 8;
+        CONSTANT_SIZE[CONSTANT_DOUBLE] = 8;
+    }
+
+    /**
+     * Every Groovy-package class name mentioned in the constant pool: class
+     * references are stored as bare internal names, while field, method,
+     * generic-signature and annotation references embed them in descriptors.
+     */
+    static Set<String> groovyReferences(byte[] classFile) throws IOException {
+        DataInputStream in = new DataInputStream(new 
ByteArrayInputStream(classFile));
+        in.readInt(); // magic
+        in.readUnsignedShort(); // minor version
+        in.readUnsignedShort(); // major version
+        int count = in.readUnsignedShort();
+        Set<String> refs = new TreeSet<>();
+        for (int i = 1; i < count; i++) {
+            int tag = in.readUnsignedByte();
+            if (tag == CONSTANT_UTF8) {
+                collectGroovyNames(in.readUTF(), refs);
+                continue;
+            }
+            int size = tag < CONSTANT_SIZE.length ? CONSTANT_SIZE[tag] : 0;
+            if (size == 0) throw new IllegalStateException("unknown constant 
pool tag " + tag);
+            if (in.skipBytes(size) != size) throw new 
IllegalStateException("truncated constant pool");
+            if (tag == CONSTANT_LONG || tag == CONSTANT_DOUBLE) i++; // these 
take two slots
+        }
+        return refs;
+    }
+
+    private static void collectGroovyNames(String text, Set<String> refs) {
+        if (BARE_NAME.matcher(text).matches()) refs.add(text);
+        Matcher m = DESCRIPTOR.matcher(text);
+        while (m.find()) refs.add(m.group(1));
+    }
+}
diff --git 
a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/PoolTest.java
 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/PoolTest.java
new file mode 100644
index 0000000000..d518da63f2
--- /dev/null
+++ 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/PoolTest.java
@@ -0,0 +1,63 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.concurrent.java;
+
+import groovy.concurrent.AsyncScope;
+import groovy.concurrent.Awaitable;
+import groovy.concurrent.ParallelScope;
+import groovy.concurrent.Pool;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.apache.groovy.runtime.async.AsyncSupport.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class PoolTest {
+
+    @Test
+    void scopeRunsOnAPool() {
+        try (Pool pool = Pool.cpu()) {
+            String result = AsyncScope.withScope(pool, scope -> {
+                Awaitable<String> a = scope.async(() -> "hello");
+                Awaitable<String> b = scope.async(() -> "world");
+                return await(a) + " " + await(b);
+            });
+            assertEquals("hello world", result);
+        }
+    }
+
+    @Test
+    void parallelScopeWithPool() {
+        int result = ParallelScope.withPool(4, scope -> {
+            List<Awaitable<Integer>> tasks = new ArrayList<>();
+            for (int i = 1; i <= 4; i++) {
+                int n = i;
+                tasks.add(scope.async(() -> n * 10));
+            }
+            int sum = 0;
+            for (Awaitable<Integer> task : tasks) {
+                sum += await(task);
+            }
+            return sum;
+        });
+        assertEquals(100, result);
+    }
+}
diff --git 
a/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/package-info.java
 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/package-info.java
new file mode 100644
index 0000000000..a63a9178eb
--- /dev/null
+++ 
b/subprojects/groovy-concurrent-java/src/test/java/org/apache/groovy/concurrent/java/package-info.java
@@ -0,0 +1,23 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+/**
+ * Consumer tests for the {@code groovy-concurrent-java} module, written in
+ * plain Java and run against the module jar without the Groovy runtime.
+ */
+package org.apache.groovy.concurrent.java;

Reply via email to