From b9ad1c64a7dbe102b879aa6d1acc887bdb84a7b9 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 15:15:16 -0400 Subject: [PATCH 01/12] feat(java): add JDK 25 default executor Use a multi-release JAR to select virtual threads as the default internal executor on JDK 25+, while retaining the common pool on older JDKs. Keep user-provided executors caller-owned and only shut down SDK-owned defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- java/README.md | 12 +- java/pom.xml | 46 +++++++ .../com/github/copilot/CopilotClient.java | 39 +++--- .../copilot/DefaultExecutorProvider.java | 22 ++++ .../copilot/rpc/CopilotClientOptions.java | 21 +-- .../copilot/DefaultExecutorProvider.java | 23 ++++ .../copilot/DefaultExecutorProviderTest.java | 124 ++++++++++++++++++ 7 files changed, 252 insertions(+), 35 deletions(-) create mode 100644 java/src/main/java/com/github/copilot/DefaultExecutorProvider.java create mode 100644 java/src/main/java25/com/github/copilot/DefaultExecutorProvider.java create mode 100644 java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java diff --git a/java/README.md b/java/README.md index b50bc0124..5cf55f06a 100644 --- a/java/README.md +++ b/java/README.md @@ -24,7 +24,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements -- Java 17 or later. **JDK 25 recommended**. Selecting JDK 25 enables the use of virtual threads, as shown in the [Quick Start](#quick-start). +- Java 17 or later. **JDK 25 recommended**. On JDK 25 and later, the SDK automatically uses virtual threads for its default internal executor. - GitHub Copilot CLI 1.0.17 or later installed and in `PATH` (or provide custom `cliPath`) ### Maven @@ -69,23 +69,16 @@ implementation 'com.github:copilot-sdk-java:1.0.0-beta-java.4' import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; -import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.SessionConfig; -import java.util.concurrent.Executors; - public class CopilotSDK { public static void main(String[] args) throws Exception { var lastMessage = new String[]{null}; // Create and start client - try (var client = new CopilotClient()) { // JDK 25+: comment out this line - // JDK 25+: uncomment the following 3 lines for virtual thread support - // var options = new CopilotClientOptions() - // .setExecutor(Executors.newVirtualThreadPerTaskExecutor()); - // try (var client = new CopilotClient(options)) { + try (var client = new CopilotClient()) { client.start().get(); // Create a session @@ -212,4 +205,3 @@ MIT — see [LICENSE](LICENSE) for details. [![Star History Chart](https://api.star-history.com/svg?repos=github/copilot-sdk-java&type=Date)](https://www.star-history.com/#github/copilot-sdk-java&Date) ⭐ Drop a star if you find this useful! - diff --git a/java/pom.xml b/java/pom.xml index 21628db7e..4d3a3e1d1 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -447,6 +447,10 @@ ${project.build.directory}/jacoco-test-results/sdk-tests.exec ${project.reporting.outputDirectory}/jacoco-coverage + + + META-INF/versions/**/*.class + @@ -507,6 +511,48 @@ -XX:+EnableDynamicAgentLoading + + java25-multi-release + + [25,) + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + compile-java25 + compile + + compile + + + 25 + false + + ${project.basedir}/src/main/java25 + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + + skip-test-harness diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 20c7a7e7d..8feb388d7 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -14,6 +14,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -78,6 +79,8 @@ public final class CopilotClient implements AutoCloseable { public static final int AUTOCLOSEABLE_TIMEOUT_SECONDS = 10; private static final int FORCE_KILL_TIMEOUT_SECONDS = 10; private final CopilotClientOptions options; + private final Executor executor; + private final ExecutorService ownedExecutor; private final CliServerManager serverManager; private final LifecycleEventManager lifecycleManager = new LifecycleEventManager(); private final Map sessions = new ConcurrentHashMap<>(); @@ -153,6 +156,11 @@ public CopilotClient(CopilotClientOptions options) { this.optionsPort = null; } + Executor providedExecutor = this.options.getExecutor(); + this.executor = providedExecutor != null ? providedExecutor : DefaultExecutorProvider.create(); + this.ownedExecutor = providedExecutor == null && DefaultExecutorProvider.isOwned(this.executor) + && this.executor instanceof ExecutorService executorService ? executorService : null; + this.serverManager = new CliServerManager(this.options); this.serverManager.setConnectionToken(this.effectiveConnectionToken); } @@ -176,11 +184,8 @@ public CompletableFuture start() { private CompletableFuture startCore() { LOG.fine("Starting Copilot client"); - Executor exec = options.getExecutor(); try { - return exec != null - ? CompletableFuture.supplyAsync(this::startCoreBody, exec) - : CompletableFuture.supplyAsync(this::startCoreBody); + return CompletableFuture.supplyAsync(this::startCoreBody, executor); } catch (RejectedExecutionException e) { return CompletableFuture.failedFuture(e); } @@ -209,8 +214,7 @@ private Connection startCoreBody() { Connection connection = new Connection(rpc, process, new ServerRpc(rpc::invoke)); // Register handlers for server-to-client calls - RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch, - options.getExecutor()); + RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch, executor); dispatcher.registerHandlers(rpc); // Verify protocol version @@ -308,7 +312,6 @@ private static boolean isUnsupportedConnectMethod(JsonRpcException ex) { */ public CompletableFuture stop() { var closeFutures = new ArrayList>(); - Executor exec = options.getExecutor(); for (CopilotSession session : new ArrayList<>(sessions.values())) { Runnable closeTask = () -> { @@ -320,9 +323,7 @@ public CompletableFuture stop() { }; CompletableFuture future; try { - future = exec != null - ? CompletableFuture.runAsync(closeTask, exec) - : CompletableFuture.runAsync(closeTask); + future = CompletableFuture.runAsync(closeTask, executor); } catch (RejectedExecutionException e) { LOG.log(Level.WARNING, "Executor rejected session close task; closing inline", e); closeTask.run(); @@ -344,7 +345,7 @@ public CompletableFuture stop() { public CompletableFuture forceStop() { disposed = true; sessions.clear(); - return cleanupConnection(); + return cleanupConnection().whenComplete((ignored, error) -> shutdownOwnedExecutor()); } private CompletableFuture cleanupConnection() { @@ -436,9 +437,7 @@ public CompletableFuture createSession(SessionConfig config) { long setupNanos = System.nanoTime(); var session = new CopilotSession(sessionId, connection.rpc); - if (options.getExecutor() != null) { - session.setExecutor(options.getExecutor()); - } + session.setExecutor(executor); SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); LoggingHelpers.logTiming(LOG, Level.FINE, @@ -524,9 +523,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS // Register the session before the RPC call to avoid missing early events. long setupNanos = System.nanoTime(); var session = new CopilotSession(sessionId, connection.rpc); - if (options.getExecutor() != null) { - session.setExecutor(options.getExecutor()); - } + session.setExecutor(executor); SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); LoggingHelpers.logTiming(LOG, Level.FINE, @@ -923,6 +920,14 @@ public void close() { stop().get(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (Exception e) { LOG.log(Level.FINE, "Error during close", e); + } finally { + shutdownOwnedExecutor(); + } + } + + private void shutdownOwnedExecutor() { + if (ownedExecutor != null) { + ownedExecutor.shutdown(); } } diff --git a/java/src/main/java/com/github/copilot/DefaultExecutorProvider.java b/java/src/main/java/com/github/copilot/DefaultExecutorProvider.java new file mode 100644 index 000000000..6967cdeb9 --- /dev/null +++ b/java/src/main/java/com/github/copilot/DefaultExecutorProvider.java @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; + +final class DefaultExecutorProvider { + + private DefaultExecutorProvider() { + } + + static Executor create() { + return ForkJoinPool.commonPool(); + } + + static boolean isOwned(Executor executor) { + return false; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index cb372d914..3d1ca15d5 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -287,9 +287,11 @@ public CopilotClientOptions setEnvironment(Map environment) { /** * Gets the executor used for internal asynchronous operations. + *

+ * Returns {@code null} if no executor has been explicitly set, indicating that + * the SDK should use its default executor strategy. * - * @return the executor, or {@code null} to use the default - * {@code ForkJoinPool.commonPool()} + * @return the executor, or {@code null} if using SDK defaults */ public Executor getExecutor() { return executor; @@ -299,15 +301,18 @@ public Executor getExecutor() { * Sets the executor used for internal asynchronous operations. *

* When provided, the SDK uses this executor for all internal - * {@code CompletableFuture} combinators instead of the default - * {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK work - * onto a dedicated thread pool or integrate with container-managed threading. + * {@code CompletableFuture} combinators. This allows callers to isolate SDK + * work onto a dedicated thread pool or integrate with container-managed + * threading. *

- * Passing {@code null} reverts to the default {@code ForkJoinPool.commonPool()} - * behavior. + * The SDK will not shut down a user-provided executor. If you pass a custom + * {@code ExecutorService}, you remain responsible for shutting it down. + *

+ * If not set (or set to {@code null}), the SDK uses its default executor: + * virtual threads on JDK 25+, {@code ForkJoinPool.commonPool()} on older JDKs. * * @param executor - * the executor to use, or {@code null} for the default + * the executor to use, or {@code null} for SDK defaults * @return this options instance for fluent chaining */ public CopilotClientOptions setExecutor(Executor executor) { diff --git a/java/src/main/java25/com/github/copilot/DefaultExecutorProvider.java b/java/src/main/java25/com/github/copilot/DefaultExecutorProvider.java new file mode 100644 index 000000000..a0a4740bb --- /dev/null +++ b/java/src/main/java25/com/github/copilot/DefaultExecutorProvider.java @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +final class DefaultExecutorProvider { + + private DefaultExecutorProvider() { + } + + static Executor create() { + return Executors.newVirtualThreadPerTaskExecutor(); + } + + static boolean isOwned(Executor executor) { + return executor instanceof ExecutorService; + } +} diff --git a/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java b/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java new file mode 100644 index 000000000..a0702e46a --- /dev/null +++ b/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.rpc.CopilotClientOptions; + +class DefaultExecutorProviderTest { + + @Test + void baseProviderUsesCommonPoolWithoutOwnership() { + Executor executor = DefaultExecutorProvider.create(); + + assertSame(ForkJoinPool.commonPool(), executor); + assertFalse(DefaultExecutorProvider.isOwned(executor)); + } + + @Test + void clientDoesNotShutDownUserProvidedExecutor() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + try (var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false).setExecutor(executor))) { + assertNotNull(client); + } + + assertFalse(executor.isShutdown()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void multiReleaseJarUsesOwnedVirtualThreadExecutorOnJdk25() throws Exception { + if (Runtime.version().feature() < 25) { + return; + } + + Path classes = Path.of("target", "classes"); + Path baseClass = classes.resolve("com/github/copilot/DefaultExecutorProvider.class"); + Path java25Class = classes.resolve("META-INF/versions/25/com/github/copilot/DefaultExecutorProvider.class"); + assertTrue(Files.exists(baseClass), "Base DefaultExecutorProvider class must be compiled"); + assertTrue(Files.exists(java25Class), "JDK 25 build must compile the multi-release executor provider"); + + Path jar = Files.createTempFile("copilot-sdk-default-executor", ".jar"); + try { + createProviderJar(jar, baseClass, java25Class); + + try (var loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { + Class provider = Class.forName("com.github.copilot.DefaultExecutorProvider", true, loader); + Method create = provider.getDeclaredMethod("create"); + Method isOwned = provider.getDeclaredMethod("isOwned", Executor.class); + create.setAccessible(true); + isOwned.setAccessible(true); + + Executor executor = (Executor) create.invoke(null); + try { + assertTrue((Boolean) isOwned.invoke(null, executor)); + CompletableFuture virtualThreadUsed = new CompletableFuture<>(); + executor.execute(() -> virtualThreadUsed.complete(isCurrentThreadVirtual())); + + assertTrue(virtualThreadUsed.get(5, TimeUnit.SECONDS)); + } finally { + if (executor instanceof ExecutorService executorService) { + executorService.shutdownNow(); + } + } + } + } finally { + Files.deleteIfExists(jar); + } + } + + private static boolean isCurrentThreadVirtual() { + try { + Method isVirtual = Thread.class.getMethod("isVirtual"); + return (Boolean) isVirtual.invoke(Thread.currentThread()); + } catch (ReflectiveOperationException e) { + return false; + } + } + + private static void createProviderJar(Path jar, Path baseClass, Path java25Class) throws IOException { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + attributes.putValue("Multi-Release", "true"); + + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + addClass(output, "com/github/copilot/DefaultExecutorProvider.class", baseClass); + addClass(output, "META-INF/versions/25/com/github/copilot/DefaultExecutorProvider.class", java25Class); + } + } + + private static void addClass(JarOutputStream output, String entryName, Path classFile) throws IOException { + output.putNextEntry(new JarEntry(entryName)); + Files.copy(classFile, output); + output.closeEntry(); + } +} From 349a419fb9e0896057b9333a4f88864449e34e6b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 15:23:06 -0400 Subject: [PATCH 02/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/com/github/copilot/CopilotClient.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 8feb388d7..fa9d00476 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -926,8 +926,21 @@ public void close() { } private void shutdownOwnedExecutor() { - if (ownedExecutor != null) { - ownedExecutor.shutdown(); + if (ownedExecutor == null) { + return; + } + + ownedExecutor.shutdown(); + try { + if (!ownedExecutor.awaitTermination(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + LOG.log(Level.FINE, "Owned executor did not terminate within {0} seconds; forcing shutdown.", + AUTOCLOSEABLE_TIMEOUT_SECONDS); + ownedExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + ownedExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + LOG.log(Level.FINE, "Interrupted while waiting for owned executor to terminate", e); } } From ed4a7b9699530f92ae19b84cc571647c3378cdea Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 15:29:39 -0400 Subject: [PATCH 03/12] test(java): cover owned default executor shutdown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot/DefaultExecutorProviderTest.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java b/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java index a0702e46a..65365809b 100644 --- a/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java +++ b/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.lang.reflect.Method; +import java.lang.reflect.Field; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; @@ -95,6 +96,38 @@ void multiReleaseJarUsesOwnedVirtualThreadExecutorOnJdk25() throws Exception { } } + @Test + void clientCloseShutsDownOwnedDefaultExecutorOnJdk25() throws Exception { + if (Runtime.version().feature() < 25) { + return; + } + + Path classes = Path.of("target", "classes"); + Path jar = Files.createTempFile("copilot-sdk-client-default-executor", ".jar"); + try { + createClassesJar(jar, classes); + + try (var loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { + Class clientClass = Class.forName("com.github.copilot.CopilotClient", true, loader); + AutoCloseable client = (AutoCloseable) clientClass.getConstructor().newInstance(); + Field ownedExecutorField = clientClass.getDeclaredField("ownedExecutor"); + ownedExecutorField.setAccessible(true); + ExecutorService ownedExecutor = (ExecutorService) ownedExecutorField.get(client); + + assertNotNull(ownedExecutor); + assertFalse(ownedExecutor.isShutdown()); + + client.close(); + + assertTrue(ownedExecutor.isShutdown()); + assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(ownedExecutor.isTerminated()); + } + } finally { + Files.deleteIfExists(jar); + } + } + private static boolean isCurrentThreadVirtual() { try { Method isVirtual = Thread.class.getMethod("isVirtual"); @@ -116,6 +149,31 @@ private static void createProviderJar(Path jar, Path baseClass, Path java25Class } } + private static void createClassesJar(Path jar, Path classes) throws IOException { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + attributes.putValue("Multi-Release", "true"); + + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest); + var files = Files.walk(classes)) { + var iterator = files.iterator(); + while (iterator.hasNext()) { + Path file = iterator.next(); + if (!Files.isRegularFile(file)) { + continue; + } + + String entryName = classes.relativize(file).toString().replace('\\', '/'); + if ("META-INF/MANIFEST.MF".equals(entryName)) { + continue; + } + + addClass(output, entryName, file); + } + } + } + private static void addClass(JarOutputStream output, String entryName, Path classFile) throws IOException { output.putNextEntry(new JarEntry(entryName)); Files.copy(classFile, output); From cab403a6536d04a5ef32d0f6a566cd06d4fd191a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 16:26:11 -0400 Subject: [PATCH 04/12] refactor(java): make default executor provider internal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/github/copilot/CopilotClient.java | 4 +-- ...der.java => InternalExecutorProvider.java} | 4 +-- ...der.java => InternalExecutorProvider.java} | 4 +-- ...java => InternalExecutorProviderTest.java} | 26 ++++++++++--------- 4 files changed, 20 insertions(+), 18 deletions(-) rename java/src/main/java/com/github/copilot/{DefaultExecutorProvider.java => InternalExecutorProvider.java} (86%) rename java/src/main/java25/com/github/copilot/{DefaultExecutorProvider.java => InternalExecutorProvider.java} (88%) rename java/src/test/java/com/github/copilot/{DefaultExecutorProviderTest.java => InternalExecutorProviderTest.java} (88%) diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index fa9d00476..b4bd5f2bd 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -157,8 +157,8 @@ public CopilotClient(CopilotClientOptions options) { } Executor providedExecutor = this.options.getExecutor(); - this.executor = providedExecutor != null ? providedExecutor : DefaultExecutorProvider.create(); - this.ownedExecutor = providedExecutor == null && DefaultExecutorProvider.isOwned(this.executor) + this.executor = providedExecutor != null ? providedExecutor : InternalExecutorProvider.create(); + this.ownedExecutor = providedExecutor == null && InternalExecutorProvider.isOwned(this.executor) && this.executor instanceof ExecutorService executorService ? executorService : null; this.serverManager = new CliServerManager(this.options); diff --git a/java/src/main/java/com/github/copilot/DefaultExecutorProvider.java b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java similarity index 86% rename from java/src/main/java/com/github/copilot/DefaultExecutorProvider.java rename to java/src/main/java/com/github/copilot/InternalExecutorProvider.java index 6967cdeb9..8657027e8 100644 --- a/java/src/main/java/com/github/copilot/DefaultExecutorProvider.java +++ b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java @@ -7,9 +7,9 @@ import java.util.concurrent.Executor; import java.util.concurrent.ForkJoinPool; -final class DefaultExecutorProvider { +final class InternalExecutorProvider { - private DefaultExecutorProvider() { + private InternalExecutorProvider() { } static Executor create() { diff --git a/java/src/main/java25/com/github/copilot/DefaultExecutorProvider.java b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java similarity index 88% rename from java/src/main/java25/com/github/copilot/DefaultExecutorProvider.java rename to java/src/main/java25/com/github/copilot/InternalExecutorProvider.java index a0a4740bb..257d0f61e 100644 --- a/java/src/main/java25/com/github/copilot/DefaultExecutorProvider.java +++ b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java @@ -8,9 +8,9 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -final class DefaultExecutorProvider { +final class InternalExecutorProvider { - private DefaultExecutorProvider() { + private InternalExecutorProvider() { } static Executor create() { diff --git a/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java similarity index 88% rename from java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java rename to java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java index 65365809b..7ec4a420d 100644 --- a/java/src/test/java/com/github/copilot/DefaultExecutorProviderTest.java +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java @@ -10,8 +10,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; -import java.lang.reflect.Method; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; @@ -31,14 +32,15 @@ import com.github.copilot.rpc.CopilotClientOptions; -class DefaultExecutorProviderTest { +class InternalExecutorProviderTest { @Test void baseProviderUsesCommonPoolWithoutOwnership() { - Executor executor = DefaultExecutorProvider.create(); + Executor executor = InternalExecutorProvider.create(); assertSame(ForkJoinPool.commonPool(), executor); - assertFalse(DefaultExecutorProvider.isOwned(executor)); + assertFalse(InternalExecutorProvider.isOwned(executor)); + assertFalse(Modifier.isPublic(InternalExecutorProvider.class.getModifiers())); } @Test @@ -62,17 +64,17 @@ void multiReleaseJarUsesOwnedVirtualThreadExecutorOnJdk25() throws Exception { } Path classes = Path.of("target", "classes"); - Path baseClass = classes.resolve("com/github/copilot/DefaultExecutorProvider.class"); - Path java25Class = classes.resolve("META-INF/versions/25/com/github/copilot/DefaultExecutorProvider.class"); - assertTrue(Files.exists(baseClass), "Base DefaultExecutorProvider class must be compiled"); + Path baseClass = classes.resolve("com/github/copilot/InternalExecutorProvider.class"); + Path java25Class = classes.resolve("META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class"); + assertTrue(Files.exists(baseClass), "Base InternalExecutorProvider class must be compiled"); assertTrue(Files.exists(java25Class), "JDK 25 build must compile the multi-release executor provider"); - Path jar = Files.createTempFile("copilot-sdk-default-executor", ".jar"); + Path jar = Files.createTempFile("copilot-sdk-internal-executor", ".jar"); try { createProviderJar(jar, baseClass, java25Class); try (var loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { - Class provider = Class.forName("com.github.copilot.DefaultExecutorProvider", true, loader); + Class provider = Class.forName("com.github.copilot.InternalExecutorProvider", true, loader); Method create = provider.getDeclaredMethod("create"); Method isOwned = provider.getDeclaredMethod("isOwned", Executor.class); create.setAccessible(true); @@ -103,7 +105,7 @@ void clientCloseShutsDownOwnedDefaultExecutorOnJdk25() throws Exception { } Path classes = Path.of("target", "classes"); - Path jar = Files.createTempFile("copilot-sdk-client-default-executor", ".jar"); + Path jar = Files.createTempFile("copilot-sdk-client-internal-executor", ".jar"); try { createClassesJar(jar, classes); @@ -144,8 +146,8 @@ private static void createProviderJar(Path jar, Path baseClass, Path java25Class attributes.putValue("Multi-Release", "true"); try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest)) { - addClass(output, "com/github/copilot/DefaultExecutorProvider.class", baseClass); - addClass(output, "META-INF/versions/25/com/github/copilot/DefaultExecutorProvider.class", java25Class); + addClass(output, "com/github/copilot/InternalExecutorProvider.class", baseClass); + addClass(output, "META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class", java25Class); } } From c5b8900c2e3b9eefa96daaabb8fd843b37891838 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:12:54 -0400 Subject: [PATCH 05/12] refactor(java): update InternalExecutorProvider to manage executor ownership and shutdown capability --- .../com/github/copilot/CopilotClient.java | 25 +++++++++------- .../copilot/InternalExecutorProvider.java | 18 ++++++++--- .../copilot/InternalExecutorProvider.java | 25 ++++++++++++---- .../copilot/InternalExecutorProviderTest.java | 30 +++++++++++-------- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index b4bd5f2bd..c7de7fe33 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -80,7 +80,7 @@ public final class CopilotClient implements AutoCloseable { private static final int FORCE_KILL_TIMEOUT_SECONDS = 10; private final CopilotClientOptions options; private final Executor executor; - private final ExecutorService ownedExecutor; + private final boolean executorCanBeShutdown; private final CliServerManager serverManager; private final LifecycleEventManager lifecycleManager = new LifecycleEventManager(); private final Map sessions = new ConcurrentHashMap<>(); @@ -156,10 +156,9 @@ public CopilotClient(CopilotClientOptions options) { this.optionsPort = null; } - Executor providedExecutor = this.options.getExecutor(); - this.executor = providedExecutor != null ? providedExecutor : InternalExecutorProvider.create(); - this.ownedExecutor = providedExecutor == null && InternalExecutorProvider.isOwned(this.executor) - && this.executor instanceof ExecutorService executorService ? executorService : null; + InternalExecutorProvider executorProvider = new InternalExecutorProvider(this.options.getExecutor()); + this.executor = executorProvider.get(); + this.executorCanBeShutdown = executorProvider.canBeShutdown(); this.serverManager = new CliServerManager(this.options); this.serverManager.setConnectionToken(this.effectiveConnectionToken); @@ -926,19 +925,25 @@ public void close() { } private void shutdownOwnedExecutor() { - if (ownedExecutor == null) { + if (!executorCanBeShutdown) { return; } - ownedExecutor.shutdown(); + ExecutorService serviceToShutdown = executor instanceof ExecutorService es ? es : null; + if (serviceToShutdown == null) { + LOG.log(Level.FINE, "Executor is not an ExecutorService; skipping shutdown"); + return; + } + + serviceToShutdown.shutdown(); try { - if (!ownedExecutor.awaitTermination(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + if (!serviceToShutdown.awaitTermination(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { LOG.log(Level.FINE, "Owned executor did not terminate within {0} seconds; forcing shutdown.", AUTOCLOSEABLE_TIMEOUT_SECONDS); - ownedExecutor.shutdownNow(); + serviceToShutdown.shutdownNow(); } } catch (InterruptedException e) { - ownedExecutor.shutdownNow(); + serviceToShutdown.shutdownNow(); Thread.currentThread().interrupt(); LOG.log(Level.FINE, "Interrupted while waiting for owned executor to terminate", e); } diff --git a/java/src/main/java/com/github/copilot/InternalExecutorProvider.java b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java index 8657027e8..b4f4f084d 100644 --- a/java/src/main/java/com/github/copilot/InternalExecutorProvider.java +++ b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java @@ -9,14 +9,24 @@ final class InternalExecutorProvider { - private InternalExecutorProvider() { + private final Executor executor; + + InternalExecutorProvider(Executor userProvided) { + if (userProvided != null) { + this.executor = userProvided; + } else { + this.executor = ForkJoinPool.commonPool(); + } } - static Executor create() { - return ForkJoinPool.commonPool(); + Executor get() { + return executor; } - static boolean isOwned(Executor executor) { + boolean canBeShutdown() { + // Since we are using ForkJoinPool.commonPool() or user provided only, + // we should not attempt to shut it down return false; } + } diff --git a/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java index 257d0f61e..508515590 100644 --- a/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java +++ b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java @@ -5,19 +5,32 @@ package com.github.copilot; import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; final class InternalExecutorProvider { - private InternalExecutorProvider() { + private final Executor executor; + private final boolean owned; + + InternalExecutorProvider(Executor userProvided) { + if (userProvided != null) { + this.executor = userProvided; + this.owned = false; + } else { + this.executor = Executors.newVirtualThreadPerTaskExecutor(); + this.owned = true; + } } - static Executor create() { - return Executors.newVirtualThreadPerTaskExecutor(); + Executor get() { + return executor; } - static boolean isOwned(Executor executor) { - return executor instanceof ExecutorService; + boolean canBeShutdown() { + // We can only shut down the executor if we created it (i.e., if it's owned) + // such as when using Executors.newVirtualThreadPerTaskExecutor(), + // which creates an executor that we are responsible for shutting down. + return owned; } } diff --git a/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java index 7ec4a420d..5ff52a648 100644 --- a/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java @@ -36,10 +36,10 @@ class InternalExecutorProviderTest { @Test void baseProviderUsesCommonPoolWithoutOwnership() { - Executor executor = InternalExecutorProvider.create(); + Executor executor = new InternalExecutorProvider(null).get(); assertSame(ForkJoinPool.commonPool(), executor); - assertFalse(InternalExecutorProvider.isOwned(executor)); + assertFalse(new InternalExecutorProvider(executor).canBeShutdown()); assertFalse(Modifier.isPublic(InternalExecutorProvider.class.getModifiers())); } @@ -75,14 +75,17 @@ void multiReleaseJarUsesOwnedVirtualThreadExecutorOnJdk25() throws Exception { try (var loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { Class provider = Class.forName("com.github.copilot.InternalExecutorProvider", true, loader); - Method create = provider.getDeclaredMethod("create"); - Method isOwned = provider.getDeclaredMethod("isOwned", Executor.class); - create.setAccessible(true); - isOwned.setAccessible(true); - - Executor executor = (Executor) create.invoke(null); + var constructor = provider.getDeclaredConstructor(Executor.class); + Method get = provider.getDeclaredMethod("get"); + Method canBeShutdown = provider.getDeclaredMethod("canBeShutdown"); + constructor.setAccessible(true); + get.setAccessible(true); + canBeShutdown.setAccessible(true); + + Object providerInstance = constructor.newInstance((Executor) null); + Executor executor = (Executor) get.invoke(providerInstance); try { - assertTrue((Boolean) isOwned.invoke(null, executor)); + assertTrue((Boolean) canBeShutdown.invoke(providerInstance)); CompletableFuture virtualThreadUsed = new CompletableFuture<>(); executor.execute(() -> virtualThreadUsed.complete(isCurrentThreadVirtual())); @@ -112,11 +115,14 @@ void clientCloseShutsDownOwnedDefaultExecutorOnJdk25() throws Exception { try (var loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { Class clientClass = Class.forName("com.github.copilot.CopilotClient", true, loader); AutoCloseable client = (AutoCloseable) clientClass.getConstructor().newInstance(); - Field ownedExecutorField = clientClass.getDeclaredField("ownedExecutor"); - ownedExecutorField.setAccessible(true); - ExecutorService ownedExecutor = (ExecutorService) ownedExecutorField.get(client); + Field executorField = clientClass.getDeclaredField("executor"); + Field executorCanBeShutdownField = clientClass.getDeclaredField("executorCanBeShutdown"); + executorField.setAccessible(true); + executorCanBeShutdownField.setAccessible(true); + ExecutorService ownedExecutor = (ExecutorService) executorField.get(client); assertNotNull(ownedExecutor); + assertTrue((Boolean) executorCanBeShutdownField.get(client)); assertFalse(ownedExecutor.isShutdown()); client.close(); From 075ab898f47cbe726e40bc41f5d818ee960a068b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:25:39 -0400 Subject: [PATCH 06/12] feat(java): add integration tests for multi-release JAR behavior and executor management --- java/pom.xml | 27 ++++ .../copilot/InternalExecutorProviderIT.java | 111 ++++++++++++++ .../InternalExecutorProviderProbe.java | 74 +++++++++ .../copilot/InternalExecutorProviderTest.java | 145 ------------------ 4 files changed, 212 insertions(+), 145 deletions(-) create mode 100644 java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java create mode 100644 java/src/test/java/com/github/copilot/InternalExecutorProviderProbe.java diff --git a/java/pom.xml b/java/pom.xml index 4d3a3e1d1..0fa06cbdd 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -307,6 +307,33 @@ + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.5 + + + + integration-test + verify + + + + + + ${project.build.directory} + ${project.build.finalName} + ${project.build.testOutputDirectory} + + + org.apache.maven.plugins maven-surefire-plugin diff --git a/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java new file mode 100644 index 000000000..a123647ce --- /dev/null +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +/** + * Failsafe integration test that asserts the multi-release behaviour of + * {@link InternalExecutorProvider} against the actually packaged JAR. + *

+ * Runs after {@code package}, when {@code target/${finalName}.jar} exists with + * its real {@code Multi-Release: true} manifest and (on JDK 25+ builds) the + * {@code META-INF/versions/25/} override produced by {@code maven-jar-plugin}. + *

+ * The test spawns a child JVM with the packaged JAR plus {@code test-classes} + * on the classpath, runs {@link InternalExecutorProviderProbe}, and asserts + * that the executor selected for the current runtime matches expectations. + */ +class InternalExecutorProviderIT { + + @Test + void packagedJarSelectsExecutorPerRuntimeVersion() throws Exception { + Path packagedJar = locatePackagedJar(); + Path testClasses = locateTestClassesDir(); + String javaBin = locateJavaBinary(); + + String classpath = packagedJar.toString() + File.pathSeparator + testClasses.toString(); + Process process = new ProcessBuilder(javaBin, "-cp", classpath, + "com.github.copilot.InternalExecutorProviderProbe") + .redirectErrorStream(true) + .start(); + + String output; + try { + output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(30, TimeUnit.SECONDS), + "Probe JVM did not exit within 30s. Output:\n" + output); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + } + } + + assertEquals(0, process.exitValue(), "Probe exited non-zero. Output:\n" + output); + + Map kv = parseKeyValues(output); + String featureRaw = kv.get("feature"); + assertNotNull(featureRaw, "Probe did not report 'feature'. Output:\n" + output); + int feature = Integer.parseInt(featureRaw); + + boolean expectOwnedVirtual = feature >= 25; + assertEquals(String.valueOf(expectOwnedVirtual), kv.get("canBeShutdown"), + "canBeShutdown mismatch for JDK feature=" + feature + ". Output:\n" + output); + assertEquals(String.valueOf(expectOwnedVirtual), kv.get("virtual"), + "virtual mismatch for JDK feature=" + feature + ". Output:\n" + output); + } + + private static Path locatePackagedJar() { + String buildDir = System.getProperty("project.build.directory"); + String finalName = System.getProperty("project.build.finalName"); + assertNotNull(buildDir, "System property 'project.build.directory' must be set by failsafe"); + assertNotNull(finalName, "System property 'project.build.finalName' must be set by failsafe"); + Path jar = Path.of(buildDir, finalName + ".jar"); + assertTrue(Files.isRegularFile(jar), "Packaged JAR must exist: " + jar); + return jar; + } + + private static Path locateTestClassesDir() { + String testOutput = System.getProperty("project.build.testOutputDirectory"); + assertNotNull(testOutput, "System property 'project.build.testOutputDirectory' must be set by failsafe"); + Path dir = Path.of(testOutput); + assertTrue(Files.isDirectory(dir), "test-classes dir must exist: " + dir); + return dir; + } + + private static String locateJavaBinary() { + Path javaHome = Path.of(System.getProperty("java.home")); + Path candidate = javaHome.resolve("bin").resolve(isWindows() ? "java.exe" : "java"); + assertTrue(Files.isExecutable(candidate), "java binary must be executable: " + candidate); + return candidate.toString(); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + private static Map parseKeyValues(String output) { + Map map = new HashMap<>(); + for (String line : output.split("\\R")) { + int eq = line.indexOf('='); + if (eq > 0) { + map.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim()); + } + } + return map; + } +} diff --git a/java/src/test/java/com/github/copilot/InternalExecutorProviderProbe.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderProbe.java new file mode 100644 index 000000000..85d12f14f --- /dev/null +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderProbe.java @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Diagnostic main launched as a separate JVM by + * {@code InternalExecutorProviderIT} to inspect the multi-release behaviour of + * {@link InternalExecutorProvider} against the actually packaged JAR. + *

+ * Lives in the same package as {@link InternalExecutorProvider} so it can use + * its package-private API directly, without reflection. + *

+ * Output format (key=value, one per line): + * + *

+ *   feature=<JDK feature version>
+ *   canBeShutdown=<true|false>
+ *   virtual=<true|false>
+ * 
+ */ +final class InternalExecutorProviderProbe { + + private InternalExecutorProviderProbe() { + } + + public static void main(String[] args) throws Exception { + InternalExecutorProvider provider = new InternalExecutorProvider(null); + Executor executor = provider.get(); + boolean canBeShutdown = provider.canBeShutdown(); + + AtomicBoolean virtual = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + executor.execute(() -> { + try { + virtual.set(isCurrentThreadVirtual()); + } finally { + latch.countDown(); + } + }); + + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + System.out.println("error=task-timeout"); + System.exit(2); + } + } finally { + if (executor instanceof ExecutorService es) { + es.shutdownNow(); + } + } + + System.out.println("feature=" + Runtime.version().feature()); + System.out.println("canBeShutdown=" + canBeShutdown); + System.out.println("virtual=" + virtual.get()); + } + + private static boolean isCurrentThreadVirtual() { + try { + Method isVirtual = Thread.class.getMethod("isVirtual"); + return (Boolean) isVirtual.invoke(Thread.currentThread()); + } catch (ReflectiveOperationException e) { + return false; + } + } +} diff --git a/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java index 5ff52a648..476b35004 100644 --- a/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java @@ -7,26 +7,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.IOException; -import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.net.URL; -import java.net.URLClassLoader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.TimeUnit; -import java.util.jar.Attributes; -import java.util.jar.JarEntry; -import java.util.jar.JarOutputStream; -import java.util.jar.Manifest; import org.junit.jupiter.api.Test; @@ -56,135 +42,4 @@ void clientDoesNotShutDownUserProvidedExecutor() { executor.shutdownNow(); } } - - @Test - void multiReleaseJarUsesOwnedVirtualThreadExecutorOnJdk25() throws Exception { - if (Runtime.version().feature() < 25) { - return; - } - - Path classes = Path.of("target", "classes"); - Path baseClass = classes.resolve("com/github/copilot/InternalExecutorProvider.class"); - Path java25Class = classes.resolve("META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class"); - assertTrue(Files.exists(baseClass), "Base InternalExecutorProvider class must be compiled"); - assertTrue(Files.exists(java25Class), "JDK 25 build must compile the multi-release executor provider"); - - Path jar = Files.createTempFile("copilot-sdk-internal-executor", ".jar"); - try { - createProviderJar(jar, baseClass, java25Class); - - try (var loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { - Class provider = Class.forName("com.github.copilot.InternalExecutorProvider", true, loader); - var constructor = provider.getDeclaredConstructor(Executor.class); - Method get = provider.getDeclaredMethod("get"); - Method canBeShutdown = provider.getDeclaredMethod("canBeShutdown"); - constructor.setAccessible(true); - get.setAccessible(true); - canBeShutdown.setAccessible(true); - - Object providerInstance = constructor.newInstance((Executor) null); - Executor executor = (Executor) get.invoke(providerInstance); - try { - assertTrue((Boolean) canBeShutdown.invoke(providerInstance)); - CompletableFuture virtualThreadUsed = new CompletableFuture<>(); - executor.execute(() -> virtualThreadUsed.complete(isCurrentThreadVirtual())); - - assertTrue(virtualThreadUsed.get(5, TimeUnit.SECONDS)); - } finally { - if (executor instanceof ExecutorService executorService) { - executorService.shutdownNow(); - } - } - } - } finally { - Files.deleteIfExists(jar); - } - } - - @Test - void clientCloseShutsDownOwnedDefaultExecutorOnJdk25() throws Exception { - if (Runtime.version().feature() < 25) { - return; - } - - Path classes = Path.of("target", "classes"); - Path jar = Files.createTempFile("copilot-sdk-client-internal-executor", ".jar"); - try { - createClassesJar(jar, classes); - - try (var loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { - Class clientClass = Class.forName("com.github.copilot.CopilotClient", true, loader); - AutoCloseable client = (AutoCloseable) clientClass.getConstructor().newInstance(); - Field executorField = clientClass.getDeclaredField("executor"); - Field executorCanBeShutdownField = clientClass.getDeclaredField("executorCanBeShutdown"); - executorField.setAccessible(true); - executorCanBeShutdownField.setAccessible(true); - ExecutorService ownedExecutor = (ExecutorService) executorField.get(client); - - assertNotNull(ownedExecutor); - assertTrue((Boolean) executorCanBeShutdownField.get(client)); - assertFalse(ownedExecutor.isShutdown()); - - client.close(); - - assertTrue(ownedExecutor.isShutdown()); - assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS)); - assertTrue(ownedExecutor.isTerminated()); - } - } finally { - Files.deleteIfExists(jar); - } - } - - private static boolean isCurrentThreadVirtual() { - try { - Method isVirtual = Thread.class.getMethod("isVirtual"); - return (Boolean) isVirtual.invoke(Thread.currentThread()); - } catch (ReflectiveOperationException e) { - return false; - } - } - - private static void createProviderJar(Path jar, Path baseClass, Path java25Class) throws IOException { - Manifest manifest = new Manifest(); - Attributes attributes = manifest.getMainAttributes(); - attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); - attributes.putValue("Multi-Release", "true"); - - try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest)) { - addClass(output, "com/github/copilot/InternalExecutorProvider.class", baseClass); - addClass(output, "META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class", java25Class); - } - } - - private static void createClassesJar(Path jar, Path classes) throws IOException { - Manifest manifest = new Manifest(); - Attributes attributes = manifest.getMainAttributes(); - attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); - attributes.putValue("Multi-Release", "true"); - - try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest); - var files = Files.walk(classes)) { - var iterator = files.iterator(); - while (iterator.hasNext()) { - Path file = iterator.next(); - if (!Files.isRegularFile(file)) { - continue; - } - - String entryName = classes.relativize(file).toString().replace('\\', '/'); - if ("META-INF/MANIFEST.MF".equals(entryName)) { - continue; - } - - addClass(output, entryName, file); - } - } - } - - private static void addClass(JarOutputStream output, String entryName, Path classFile) throws IOException { - output.putNextEntry(new JarEntry(entryName)); - Files.copy(classFile, output); - output.closeEntry(); - } } From 8ca20ebf2709f75571cb55503b3f9d2e95338074 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:47:01 -0400 Subject: [PATCH 07/12] feat(java): add JDK 25 multi-release overlay class verification and update documentation --- java/pom.xml | 40 +++++++++++++++++++ .../copilot/InternalExecutorProvider.java | 27 +++++++++++++ .../copilot/InternalExecutorProvider.java | 29 ++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/java/pom.xml b/java/pom.xml index 0fa06cbdd..78345bd8e 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -577,6 +577,46 @@ + + + org.apache.maven.plugins + maven-antrun-plugin + + + verify-java25-overlay + package + + run + + + + + + + + + +JDK 25 multi-release overlay class is missing from the packaged JAR. +Expected entry: META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class +JAR: ${project.build.directory}/${project.build.finalName}.jar + +This usually means the 'java25-multi-release' Maven profile did not activate +(e.g. the build is running on a JDK older than 25) or maven-compiler-plugin +did not produce the multi-release output. Re-build on JDK 25+ and verify the +'compile-java25' execution ran during the 'compile' phase. + + + + + +
diff --git a/java/src/main/java/com/github/copilot/InternalExecutorProvider.java b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java index b4f4f084d..8eedbf732 100644 --- a/java/src/main/java/com/github/copilot/InternalExecutorProvider.java +++ b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java @@ -7,6 +7,33 @@ import java.util.concurrent.Executor; import java.util.concurrent.ForkJoinPool; +/** + * Resolves the {@link Executor} used by {@link CopilotClient} for internal + * asynchronous work. + * + *

This is the baseline (JDK 17+) implementation. When no + * user-provided executor is supplied, it falls back to + * {@link ForkJoinPool#commonPool()}, which is shared with the rest of the + * JVM and therefore never owned by the SDK. + * + *

Multi-release JAR contract. This class has a sibling + * variant at {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java} + * that is compiled with {@code --release 25} into + * {@code META-INF/versions/25/} and selected automatically by the JVM on + * JDK 25+. Any change to the package-private surface of this class + * ({@link #InternalExecutorProvider(Executor) constructor}, + * {@link #get()}, {@link #canBeShutdown()}) must be mirrored in + * both source trees. The two implementations must remain + * behaviourally interchangeable from the caller's perspective; only the + * default-executor strategy and ownership semantics differ. + * + * @implNote + * Maintainers: when editing this file, also edit + * {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java}. + * The packaged JAR is verified at build time (see the + * {@code java25-multi-release} profile in {@code pom.xml}) to ensure the + * JDK 25 overlay is present. + */ final class InternalExecutorProvider { private final Executor executor; diff --git a/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java index 508515590..10878bb0c 100644 --- a/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java +++ b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java @@ -8,6 +8,35 @@ import java.util.concurrent.Executors; import java.util.concurrent.ForkJoinPool; +/** + * Resolves the {@link Executor} used by {@link CopilotClient} for internal + * asynchronous work. + * + *

This is the JDK 25+ multi-release variant. It is + * compiled with {@code --release 25} into + * {@code META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class} + * inside the packaged JAR and is automatically loaded in preference to the + * baseline class when the JVM runtime feature version is 25 or greater. + * When no user-provided executor is supplied, it creates an SDK-owned + * {@link Executors#newVirtualThreadPerTaskExecutor() virtual-thread executor} + * that is shut down by {@link CopilotClient#close()}. + * + *

Multi-release JAR contract. This class is the + * JDK 25 sibling of the baseline implementation at + * {@code src/main/java/com/github/copilot/InternalExecutorProvider.java}. + * The package-private surface of both classes + * ({@link #InternalExecutorProvider(Executor) constructor}, + * {@link #get()}, {@link #canBeShutdown()}) must be kept in + * lock-step; only the default-executor strategy and ownership + * semantics differ. + * + * @implNote + * Maintainers: when editing this file, also edit + * {@code src/main/java/com/github/copilot/InternalExecutorProvider.java}. + * The packaged JAR is verified at build time (see the + * {@code java25-multi-release} profile in {@code pom.xml}) to ensure this + * overlay class is present. + */ final class InternalExecutorProvider { private final Executor executor; From 626a08f02bc676834da1f49393184cadab4998cb Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:53:34 -0400 Subject: [PATCH 08/12] test(java): split InternalExecutorProvider unit test into single-assertion tests Addresses PR #1478 review (Copilot AI): the existing baseProviderUsesCommonPoolWithoutOwnership method bundled three unrelated assertions (commonPool identity, user-executor ownership, package-private visibility). Split into baseProviderReturnsCommonPool, userProvidedExecutorIsNotOwned, and providerIsPackagePrivate so failures point at a single condition each. --- .../github/copilot/InternalExecutorProviderTest.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java index 476b35004..f1d854cb5 100644 --- a/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java @@ -21,11 +21,21 @@ class InternalExecutorProviderTest { @Test - void baseProviderUsesCommonPoolWithoutOwnership() { + void baseProviderReturnsCommonPool() { Executor executor = new InternalExecutorProvider(null).get(); assertSame(ForkJoinPool.commonPool(), executor); + } + + @Test + void userProvidedExecutorIsNotOwned() { + Executor executor = ForkJoinPool.commonPool(); + assertFalse(new InternalExecutorProvider(executor).canBeShutdown()); + } + + @Test + void providerIsPackagePrivate() { assertFalse(Modifier.isPublic(InternalExecutorProvider.class.getModifiers())); } From bb6708178162bead9b9beee385afa559c96cd558 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:06:56 -0400 Subject: [PATCH 09/12] fix(java): dispatch owned-executor shutdown off the executor in forceStop() Addresses PR #1478 review (Copilot AI, discussion r3314987809): CopilotClient#forceStop() chains shutdownOwnedExecutor() onto cleanupConnection() via whenComplete(...), but cleanupConnection() is itself built on async work scheduled on the SDK-owned executor (e.g. CompletableFuture.supplyAsync(..., executor) in connection setup). On JDK 25+ this means the whenComplete lambda can land on one of the owned executor's threads; awaitTermination(...) then blocks waiting for the very thread it is running on, forcing the full AUTOCLOSEABLE_TIMEOUT_SECONDS timeout followed by shutdownNow(). Fix: dispatch the shutdown continuation via whenCompleteAsync(...) onto a private one-shot SHUTDOWN_DISPATCHER that spawns a fresh daemon thread named "copilot-client-shutdown". This guarantees the awaitTermination call is never made from inside the executor it is draining. close() is unaffected: it calls stop().get(...) synchronously and runs shutdownOwnedExecutor() in its finally block on the caller's thread. --- .../com/github/copilot/CopilotClient.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 2ade194d1..34e381513 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -81,6 +81,21 @@ public final class CopilotClient implements AutoCloseable { */ public static final int AUTOCLOSEABLE_TIMEOUT_SECONDS = 10; private static final int FORCE_KILL_TIMEOUT_SECONDS = 10; + + /** + * One-shot dispatcher used to run the owned-executor shutdown off any caller + * thread that might itself belong to that executor (e.g. the + * {@link #forceStop()} continuation, which is chained off async work scheduled + * on the internal executor). Spawning a fresh daemon thread guarantees + * {@link java.util.concurrent.ExecutorService#awaitTermination(long, TimeUnit)} + * is never called from inside the very executor it is waiting on. + */ + private static final Executor SHUTDOWN_DISPATCHER = runnable -> { + Thread t = new Thread(runnable, "copilot-client-shutdown"); + t.setDaemon(true); + t.start(); + }; + private final CopilotClientOptions options; private final Executor executor; private final boolean executorCanBeShutdown; @@ -359,7 +374,12 @@ public CompletableFuture stop() { public CompletableFuture forceStop() { disposed = true; sessions.clear(); - return cleanupConnection().whenComplete((ignored, error) -> shutdownOwnedExecutor()); + // Dispatch the blocking shutdownOwnedExecutor() on a dedicated thread: + // cleanupConnection() is chained off async work running on the owned + // executor, so a plain whenComplete(...) here could land the awaitTermination + // call on one of the very threads it is waiting to drain, forcing the full + // AUTOCLOSEABLE_TIMEOUT_SECONDS timeout followed by shutdownNow(). + return cleanupConnection().whenCompleteAsync((ignored, error) -> shutdownOwnedExecutor(), SHUTDOWN_DISPATCHER); } private CompletableFuture cleanupConnection() { From e2d5fa8734c75f2c6e9cceaa43df1a93b3309abc Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:11:54 -0400 Subject: [PATCH 10/12] fix(java): short-circuit shutdownOwnedExecutor() when already shut down Addresses PR #1478 review (Copilot AI, discussion r3314987870): close() and forceStop() can each invoke shutdownOwnedExecutor() (e.g. user calls forceStop() and then close() in try-with-resources). A second call would redundantly invoke shutdown() and awaitTermination() on an already- terminated ExecutorService. While the JDK handles this gracefully (awaitTermination returns immediately after a prior shutdownNow), the redundant call obscures diagnostics. Short-circuit at the top of shutdownOwnedExecutor() when isShutdown() is already true and log at FINE so the second invocation is visible without spamming normal output. --- java/src/main/java/com/github/copilot/CopilotClient.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 34e381513..4a9e48731 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -1143,6 +1143,15 @@ private void shutdownOwnedExecutor() { return; } + // Short-circuit when the owned executor is already shut down. close() and + // forceStop() can each call this method (e.g. forceStop() invoked before a + // subsequent close() in user code), and re-entering shutdown() + awaitTermination() + // is redundant. Logging at FINE aids diagnostics without spamming normal output. + if (serviceToShutdown.isShutdown()) { + LOG.log(Level.FINE, "Owned executor was already shut down; skipping redundant shutdown call."); + return; + } + serviceToShutdown.shutdown(); try { if (!serviceToShutdown.awaitTermination(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { From 67adf3e2d885fdd5641021d6b4f1d474458b9f31 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:13:50 -0400 Subject: [PATCH 11/12] spotless:apply --- .../com/github/copilot/CopilotClient.java | 6 ++- .../copilot/InternalExecutorProvider.java | 41 ++++++++++--------- .../copilot/InternalExecutorProviderIT.java | 7 +--- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 4a9e48731..00d5ea749 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -1145,8 +1145,10 @@ private void shutdownOwnedExecutor() { // Short-circuit when the owned executor is already shut down. close() and // forceStop() can each call this method (e.g. forceStop() invoked before a - // subsequent close() in user code), and re-entering shutdown() + awaitTermination() - // is redundant. Logging at FINE aids diagnostics without spamming normal output. + // subsequent close() in user code), and re-entering shutdown() + + // awaitTermination() + // is redundant. Logging at FINE aids diagnostics without spamming normal + // output. if (serviceToShutdown.isShutdown()) { LOG.log(Level.FINE, "Owned executor was already shut down; skipping redundant shutdown call."); return; diff --git a/java/src/main/java/com/github/copilot/InternalExecutorProvider.java b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java index 8eedbf732..284965513 100644 --- a/java/src/main/java/com/github/copilot/InternalExecutorProvider.java +++ b/java/src/main/java/com/github/copilot/InternalExecutorProvider.java @@ -11,28 +11,29 @@ * Resolves the {@link Executor} used by {@link CopilotClient} for internal * asynchronous work. * - *

This is the baseline (JDK 17+) implementation. When no + *

+ * This is the baseline (JDK 17+) implementation. When no * user-provided executor is supplied, it falls back to - * {@link ForkJoinPool#commonPool()}, which is shared with the rest of the - * JVM and therefore never owned by the SDK. + * {@link ForkJoinPool#commonPool()}, which is shared with the rest of the JVM + * and therefore never owned by the SDK. * - *

Multi-release JAR contract. This class has a sibling - * variant at {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java} - * that is compiled with {@code --release 25} into - * {@code META-INF/versions/25/} and selected automatically by the JVM on - * JDK 25+. Any change to the package-private surface of this class - * ({@link #InternalExecutorProvider(Executor) constructor}, - * {@link #get()}, {@link #canBeShutdown()}) must be mirrored in - * both source trees. The two implementations must remain - * behaviourally interchangeable from the caller's perspective; only the - * default-executor strategy and ownership semantics differ. + *

+ * Multi-release JAR contract. This class has a sibling variant + * at {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java} + * that is compiled with {@code --release 25} into {@code META-INF/versions/25/} + * and selected automatically by the JVM on JDK 25+. Any change to the + * package-private surface of this class + * ({@link #InternalExecutorProvider(Executor) constructor}, {@link #get()}, + * {@link #canBeShutdown()}) must be mirrored in both source + * trees. The two implementations must remain behaviourally + * interchangeable from the caller's perspective; only the default-executor + * strategy and ownership semantics differ. * - * @implNote - * Maintainers: when editing this file, also edit - * {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java}. - * The packaged JAR is verified at build time (see the - * {@code java25-multi-release} profile in {@code pom.xml}) to ensure the - * JDK 25 overlay is present. + * @implNote Maintainers: when editing this file, also edit + * {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java}. + * The packaged JAR is verified at build time (see the + * {@code java25-multi-release} profile in {@code pom.xml}) to ensure + * the JDK 25 overlay is present. */ final class InternalExecutorProvider { @@ -51,7 +52,7 @@ Executor get() { } boolean canBeShutdown() { - // Since we are using ForkJoinPool.commonPool() or user provided only, + // Since we are using ForkJoinPool.commonPool() or user provided only, // we should not attempt to shut it down return false; } diff --git a/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java index a123647ce..1cc6b482e 100644 --- a/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java @@ -40,15 +40,12 @@ void packagedJarSelectsExecutorPerRuntimeVersion() throws Exception { String classpath = packagedJar.toString() + File.pathSeparator + testClasses.toString(); Process process = new ProcessBuilder(javaBin, "-cp", classpath, - "com.github.copilot.InternalExecutorProviderProbe") - .redirectErrorStream(true) - .start(); + "com.github.copilot.InternalExecutorProviderProbe").redirectErrorStream(true).start(); String output; try { output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - assertTrue(process.waitFor(30, TimeUnit.SECONDS), - "Probe JVM did not exit within 30s. Output:\n" + output); + assertTrue(process.waitFor(30, TimeUnit.SECONDS), "Probe JVM did not exit within 30s. Output:\n" + output); } finally { if (process.isAlive()) { process.destroyForcibly(); From e46b72800ade7bf80d6628d73ced9e6e9218f1ea Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:19:23 -0400 Subject: [PATCH 12/12] ci(java): build/publish on JDK 25 to include MR-JAR overlay; matrix SDK tests across JDK 17 and 25 The java25-multi-release Maven profile is activated only on JDK 25+ ([25,)). Without it, the build skips the compile-java25 execution, the packaged JAR has no META-INF/versions/25/InternalExecutorProvider.class, and the manifest lacks Multi-Release: true. The InternalExecutorProvider JDK 25 overlay (Executors.newVirtualThreadPerTaskExecutor()) is then effectively dead in CI and in published Maven Central artifacts -- consumers on JDK 25+ silently fall back to ForkJoinPool.commonPool(). Changes: - java-publish-snapshot.yml: set up JDK 25 (was 17). The pom keeps 17 so baseline bytecode remains JDK 17 compatible; --release 17 is supported by the JDK 25 compiler. - java-publish-maven.yml: same JDK bump for release:perform. - java-sdk-tests.yml: matrix on java-version: [17, 25]. JDK 25 entry exercises the MR-JAR overlay end-to-end via InternalExecutorProviderIT (asserts feature >= 25 => canBeShutdown=true, virtual=true) and runs the new verify-java25-overlay antrun structural guard. Side-effects (site artifact upload, JaCoCo badge generation, badge-update PR) remain gated to the JDK 17 entry so the badge source-of-truth stays a single baseline. Failure artifact name suffixed with -jdk${matrix.java-version} to avoid collisions. Branch protection note: the job's check name changes from "Java SDK Tests" to "Java SDK Tests (JDK 17)" + "Java SDK Tests (JDK 25)". Update branch protection rules accordingly after merge if required-checks reference the old name. --- .github/workflows/java-publish-maven.yml | 4 ++-- .github/workflows/java-publish-snapshot.yml | 4 ++-- .github/workflows/java-sdk-tests.yml | 26 ++++++++++++++++----- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/java-publish-maven.yml b/.github/workflows/java-publish-maven.yml index 2f150f1b1..20b9b2054 100644 --- a/.github/workflows/java-publish-maven.yml +++ b/.github/workflows/java-publish-maven.yml @@ -54,10 +54,10 @@ jobs: - uses: ./.github/actions/setup-copilot - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: - java-version: "17" + java-version: "25" distribution: "microsoft" cache: "maven" server-id: central diff --git a/.github/workflows/java-publish-snapshot.yml b/.github/workflows/java-publish-snapshot.yml index 7bc231c73..8c957627f 100644 --- a/.github/workflows/java-publish-snapshot.yml +++ b/.github/workflows/java-publish-snapshot.yml @@ -30,10 +30,10 @@ jobs: - uses: ./.github/actions/setup-copilot - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: - java-version: "17" + java-version: "25" distribution: "microsoft" cache: "maven" server-id: central diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index 5e9b504fd..b53fe26b7 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -37,10 +37,20 @@ permissions: jobs: java-sdk: - name: "Java SDK Tests" + name: "Java SDK Tests (JDK ${{ matrix.java-version }})" if: github.event.repository.fork == false runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # JDK 17 is the baseline --release target; JDK 25 also activates the + # java25-multi-release Maven profile, producing the MR-JAR overlay + # (META-INF/versions/25/) and exercising the virtual-thread default + # executor branch end-to-end via InternalExecutorProviderIT. The + # build-time verify-java25-overlay antrun guard fires only when the + # profile is active, so it is also exercised on the JDK 25 entry. + java-version: ["17", "25"] defaults: run: shell: bash @@ -52,7 +62,7 @@ jobs: - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: - java-version: "17" + java-version: ${{ matrix.java-version }} distribution: "microsoft" cache: "maven" @@ -86,8 +96,12 @@ jobs: COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} run: mvn verify + # Side-effects below (site artifact, JaCoCo badge, badge PR) are scoped + # to the JDK 17 matrix entry so the badge source-of-truth and the + # uploaded site artifact remain a single, stable baseline regardless + # of the second matrix entry's outcome. - name: Upload test results for site generation - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.java-version == '17' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-for-site @@ -98,12 +112,12 @@ jobs: retention-days: 1 - name: Generate JaCoCo badge - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.java-version == '17' working-directory: . run: .github/scripts/generate-java-coverage-badge.sh java/target/site/jacoco-coverage/jacoco.csv .github/badges - name: Create PR for JaCoCo badge update - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.java-version == '17' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7 with: commit-message: "Update Java JaCoCo coverage badge" @@ -121,7 +135,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: java-test-results + name: java-test-results-jdk${{ matrix.java-version }} path: | java/target/surefire-reports/ java/target/surefire-reports-isolated/