From 9a553def639479eaa82b4af25d31c7ab30bf1c4d Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 15:15:16 -0400 Subject: [PATCH 01/18] 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 | 11 +- 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(+), 34 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 61e59c8d6..9b23400eb 100644 --- a/java/README.md +++ b/java/README.md @@ -21,7 +21,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 @@ -66,23 +66,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 diff --git a/java/pom.xml b/java/pom.xml index a21ae9013..801dda9b5 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 471a4bcd7..b7be355e2 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; @@ -81,6 +82,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<>(); @@ -168,6 +171,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); } @@ -191,11 +199,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); } @@ -224,8 +229,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 @@ -323,7 +327,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 = () -> { @@ -335,9 +338,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(); @@ -359,7 +360,7 @@ public CompletableFuture stop() { public CompletableFuture forceStop() { disposed = true; sessions.clear(); - return cleanupConnection(); + return cleanupConnection().whenComplete((ignored, error) -> shutdownOwnedExecutor()); } private CompletableFuture cleanupConnection() { @@ -470,9 +471,7 @@ public CompletableFuture createSession(SessionConfig config) { java.util.function.Function initializeSession = sid -> { long setupNanos = System.nanoTime(); var s = new CopilotSession(sid, connection.rpc); - if (options.getExecutor() != null) { - s.setExecutor(options.getExecutor()); - } + s.setExecutor(executor); SessionRequestBuilder.configureSession(s, config); if (extracted.transformCallbacks() != null) { s.registerTransformCallbacks(extracted.transformCallbacks()); @@ -599,9 +598,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, @@ -1148,6 +1145,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 69464aa72..941467059 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -288,9 +288,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; @@ -300,15 +302,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 8079fdfba6541dae7304620dd23445302f26e653 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 15:23:06 -0400 Subject: [PATCH 02/18] 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 b7be355e2..7a05304fa 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -1151,8 +1151,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 2082fe806362876f9039cfb4829793d206331773 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 15:29:39 -0400 Subject: [PATCH 03/18] 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 931559386646a9b1e11ea5ae9736948db33b0c21 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 16:26:11 -0400 Subject: [PATCH 04/18] 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 7a05304fa..bade6c3da 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -172,8 +172,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 4dac3f40a7a90ea6f414acb51113b47e4a38dca0 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:12:54 -0400 Subject: [PATCH 05/18] 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 bade6c3da..b9090b8a9 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -83,7 +83,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<>(); @@ -171,10 +171,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); @@ -1151,19 +1150,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 ace3cc3ddadad70af7df6fe712db8657a1ae5ee1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:25:39 -0400 Subject: [PATCH 06/18] 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 801dda9b5..67ffd9c76 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 131bb3fe1e2d37e76a049a1e64aa9b23979dad00 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:47:01 -0400 Subject: [PATCH 07/18] 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 67ffd9c76..f6d5605b2 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 0e722419cccbd021d73e26166089bde806b1d0d4 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 22:53:34 -0400 Subject: [PATCH 08/18] 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 9a069bc410548162f8cdc153399e32756e0db1d1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:06:56 -0400 Subject: [PATCH 09/18] 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 b9090b8a9..8f3b94d6e 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 048e59312a1e6f842fe551386636b6c4f40c278d Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:11:54 -0400 Subject: [PATCH 10/18] 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 8f3b94d6e..63d355054 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -1180,6 +1180,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 6544eebf99729c1eb6a14d5f4be6796cc838d6bf Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:13:50 -0400 Subject: [PATCH 11/18] 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 63d355054..36a3c78d1 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -1182,8 +1182,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 2ddcbbccef568e6e386b8836c563625938ec9fdd Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 27 May 2026 23:19:23 -0400 Subject: [PATCH 12/18] 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/ From ec3c96d0e42d5efc41cb4b4e86a67656d53bf883 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 11:26:21 -0700 Subject: [PATCH 13/18] On branch edburns/review-brunoborges-pr-1478 Test invocation changes. modified: .github/workflows/java-sdk-tests.yml - Have `test-jdk17` be a parameter, set to true by default. - Do not use matrix. It is vital that the tests verify that the MR-JAR facility works when compiled using JDK 25 with `maven.compiler.release` set to 17. To that end, this workflow does not re-compile the jar or the tests, but uses a the JDK 17 just to run the tests. - Use a separate banner for each variant. - Use separate summary for 17 and 25 tests. modified: java/pom.xml - Print banners for inspection during tests. - Use Maven enforcer plugin to require using 25 when compiling. modified: java/README.md - Add content for running the tests with 17. --- .github/workflows/java-sdk-tests.yml | 59 ++++++++++++++++++---------- java/README.md | 21 +++++----- java/pom.xml | 52 +++++++++++++++++++++++- 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index b53fe26b7..7c2223886 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -28,6 +28,12 @@ on: - "!**/*.gif" - "!**/*.svg" workflow_dispatch: + inputs: + test-jdk17: + description: "Also run tests on JDK 17 (compatibility check)" + type: boolean + required: false + default: true merge_group: permissions: @@ -37,20 +43,10 @@ permissions: jobs: java-sdk: - name: "Java SDK Tests (JDK ${{ matrix.java-version }})" + name: "Java SDK Tests" 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 @@ -62,7 +58,7 @@ jobs: - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: - java-version: ${{ matrix.java-version }} + java-version: "25" distribution: "microsoft" cache: "maven" @@ -96,12 +92,8 @@ 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' && matrix.java-version == '17' + if: success() && github.ref == 'refs/heads/main' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-for-site @@ -112,12 +104,12 @@ jobs: retention-days: 1 - name: Generate JaCoCo badge - if: success() && github.ref == 'refs/heads/main' && matrix.java-version == '17' + if: success() && github.ref == 'refs/heads/main' 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' && matrix.java-version == '17' + if: success() && github.ref == 'refs/heads/main' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7 with: commit-message: "Update Java JaCoCo coverage badge" @@ -130,13 +122,40 @@ jobs: - name: Generate Test Report Summary if: always() uses: ./.github/actions/java-test-report + with: + title: "Copilot Java SDK :: Test Results (JDK 25)" - name: Upload test results on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: java-test-results-jdk${{ matrix.java-version }} + name: java-test-results path: | java/target/surefire-reports/ java/target/surefire-reports-isolated/ retention-days: 7 + + - name: Switch to JDK 17 + if: inputs.test-jdk17 == true || inputs.test-jdk17 == null + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: "17" + distribution: "microsoft" + + - name: Re-run tests on JDK 17 (no recompilation) + if: inputs.test-jdk17 == true || inputs.test-jdk17 == null + env: + CI: "true" + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} + run: | + echo "Running tests against JDK 25-built classes using JDK 17 runtime..." + java -version + mvn antrun:run@print-test-jdk-banner surefire:test -Denforcer.skip=true -DtestExecutionAgentArgs= + + - name: Generate Test Report Summary (JDK 17) + if: always() && (inputs.test-jdk17 == true || inputs.test-jdk17 == null) + uses: ./.github/actions/java-test-report + with: + title: "Copilot Java SDK :: Test Results (JDK 17)" + show-coverage: "false" diff --git a/java/README.md b/java/README.md index 9b23400eb..53d8114cf 100644 --- a/java/README.md +++ b/java/README.md @@ -19,10 +19,10 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ## Installation -### Requirements +### Runtime requirements -- 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`) +- Java 17 or later. **JDK 25 recommended**. The distributed jar is a multi-release jar (MR-JAR) and is copiled on JDK 25 with `maven.compiler.release` set to 17. This means, when run on JDK 25 and later, the SDK automatically uses virtual threads for its default internal executor. +- GitHub Copilot CLI 1.0.55-5. or later installed and in `PATH` (or provide custom `cliPath`) ### Maven @@ -50,14 +50,14 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.0-beta-java.5-SNAPSHOT + 1.0.0-beta-10-java.0-SNAPSHOT ``` ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.0-beta-java.4' +implementation 'com.github:copilot-sdk-java:1.0.0-beta-10-java.0-SNAPSHOT' ``` ## Quick Start @@ -153,6 +153,8 @@ This SDK tracks the official [Copilot SDK](https://github.com/github/copilot-sdk ### Development Setup +Requires JDK 25 or later for development. + ```bash # Clone the repository git clone https://github.com/github/copilot-sdk.git @@ -161,8 +163,12 @@ cd copilot-sdk/java # Enable git hooks for code formatting git config core.hooksPath .githooks -# Build and test +# Build and test with JDK 25 mvn clean verify + +# Set your paths for JDK 17 +# Run the JDK 25 built jar with JDK 17 JVM for tests. Do not re-compile the jar. +mvn antrun:run@print-test-jdk-banner surefire:test -Denforcer.skip=true -DtestExecutionAgentArgs= ``` The tests require the official [copilot-sdk](https://github.com/github/copilot-sdk) test harness, which is automatically cloned during build. @@ -183,6 +189,3 @@ See [SECURITY.md](SECURITY.md) for reporting security vulnerabilities. MIT — see [LICENSE](LICENSE) for details. -## Acknowledgement - -- Initially developed with Copilot and [Bruno Borges](https://www.linkedin.com/in/brunocborges/). diff --git a/java/pom.xml b/java/pom.xml index f6d5605b2..e5d4592ee 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 1.0.0-beta-java.5-SNAPSHOT + 1.0.0-beta-10-java.0-SNAPSHOT jar GitHub Copilot SDK :: Java @@ -245,6 +245,18 @@ + + print-test-jdk-banner + process-test-classes + + run + + + + + + + @@ -520,6 +532,44 @@ true + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce-jdk25 + + enforce + + + + + [25,) + JDK 25+ is required to build the Multi-Release JAR with the virtual-thread overlay. + + + + + + verify-multi-release-overlay + verify + + enforce + + + + + + ${project.build.outputDirectory}/META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class + + Multi-Release JAR overlay missing: META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class was not compiled. Ensure the build runs on JDK 25+. + + + + + + From 8a9466e791af59ae4f056fc6dc0c08fa1eb1e390 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 11:50:18 -0700 Subject: [PATCH 14/18] On branch edburns/review-brunoborges-pr-1478 modified: .github/workflows/docs-validation.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Java 25 per @brunoborges. modified: .github/workflows/java-sdk-tests.yml - Update labels. modified: .vscode/settings.json - Additional Java settings. modified: scripts/docs-validation/package.json The `--lang` parsing in validate.ts uses `args.find((a) => a.startsWith("--lang="))`, which won't match `--lang typescript` (space-separated). So `targetLang` is always `undefined`, and every job validates **all** languages. This is a pre-existing bug — but it was invisible before because `mvn install` succeeded with the pre-installed JDK 17 on all runners. However, the reason it matters **now** is: after fixing the `validate-java` job to use JDK 25, the other 4 jobs (TypeScript, Go, Python, C#) still don't have JDK 25. Since they also accidentally validate Java (due to the broken `--lang` filter), they'd continue to fail. So you actually have two options: **Option A:** Only fix JDK in `validate-java` AND fix `--lang` parsing so other jobs stop accidentally validating Java. **Option B:** Only fix JDK in `validate-java` AND add JDK 25 setup to all 4 other jobs too (ugly but works without touching the script). The `--lang` fix is the cleaner path, but it's a separate pre-existing bug, not something introduced by PR #1483. If you'd prefer to keep the changes minimal and just address the PR's breakage, I can revert the package.json change and instead add `setup-java` with JDK 25 to every job. What's your preference? --- .github/workflows/docs-validation.yml | 2 +- .github/workflows/java-sdk-tests.yml | 8 ++++---- .vscode/settings.json | 3 ++- scripts/docs-validation/package.json | 10 +++++----- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index e5fe01d91..4f53b71e4 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -143,7 +143,7 @@ jobs: - uses: actions/setup-java@v4 with: distribution: 'microsoft' - java-version: '17' + java-version: '25' cache: 'maven' - name: Install SDK to local repo diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index 7c2223886..723642024 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -119,11 +119,11 @@ jobs: add-paths: .github/badges/ delete-branch: true - - name: Generate Test Report Summary + - name: Generate Test Report Summary JDK 25 if: always() uses: ./.github/actions/java-test-report with: - title: "Copilot Java SDK :: Test Results (JDK 25)" + title: "Copilot Java SDK :: Test Results JDK 25" - name: Upload test results on failure if: failure() @@ -153,9 +153,9 @@ jobs: java -version mvn antrun:run@print-test-jdk-banner surefire:test -Denforcer.skip=true -DtestExecutionAgentArgs= - - name: Generate Test Report Summary (JDK 17) + - name: Generate Test Report Summary JDK 17 if: always() && (inputs.test-jdk17 == true || inputs.test-jdk17 == null) uses: ./.github/actions/java-test-report with: - title: "Copilot Java SDK :: Test Results (JDK 17)" + title: "Copilot Java SDK :: Test Results JDK 17" show-coverage: "false" diff --git a/.vscode/settings.json b/.vscode/settings.json index d0d8465c3..c4ae9c761 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,6 @@ "[go]": { "editor.defaultFormatter": "golang.go" }, - "java.configuration.updateBuildConfiguration": "automatic" + "java.configuration.updateBuildConfiguration": "automatic", + "java.compile.nullAnalysis.mode": "automatic" } diff --git a/scripts/docs-validation/package.json b/scripts/docs-validation/package.json index b802f0ba3..e2f40b8ee 100644 --- a/scripts/docs-validation/package.json +++ b/scripts/docs-validation/package.json @@ -6,11 +6,11 @@ "scripts": { "extract": "tsx extract.ts", "validate": "tsx validate.ts", - "validate:ts": "tsx validate.ts --lang typescript", - "validate:py": "tsx validate.ts --lang python", - "validate:go": "tsx validate.ts --lang go", - "validate:cs": "tsx validate.ts --lang csharp", - "validate:java": "tsx validate.ts --lang java" + "validate:ts": "tsx validate.ts --lang=typescript", + "validate:py": "tsx validate.ts --lang=python", + "validate:go": "tsx validate.ts --lang=go", + "validate:cs": "tsx validate.ts --lang=csharp", + "validate:java": "tsx validate.ts --lang=java" }, "dependencies": { "glob": "^11.0.0", From 26eb7f6b1997d262f39467d8032e4652e901dd11 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 12:27:14 -0700 Subject: [PATCH 15/18] On branch edburns/review-brunoborges-pr-1478 modified: .github/actions/java-test-report/action.yml modified: .github/workflows/java-sdk-tests.yml - Ensure the correct Copilot CLI is used for tests. --- .github/actions/java-test-report/action.yml | 6 +- .github/workflows/java-sdk-tests.yml | 87 +++++++++------------ 2 files changed, 43 insertions(+), 50 deletions(-) diff --git a/.github/actions/java-test-report/action.yml b/.github/actions/java-test-report/action.yml index d5f6d2709..a7170fd62 100644 --- a/.github/actions/java-test-report/action.yml +++ b/.github/actions/java-test-report/action.yml @@ -17,13 +17,17 @@ inputs: description: "Name for the check run" required: false default: "Java SDK Test Results" + title: + description: "Title for the test report summary" + required: false + default: "Copilot Java SDK :: Test Results" runs: using: "composite" steps: - name: Generate Test Summary shell: bash run: | - echo "## 🧪 Copilot Java SDK :: Test Results" >> $GITHUB_STEP_SUMMARY + echo "## 🧪 ${{ inputs.title }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if ls ${{ inputs.report-path }} 1>/dev/null 2>&1; then diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index 723642024..abe38103f 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -28,12 +28,6 @@ on: - "!**/*.gif" - "!**/*.svg" workflow_dispatch: - inputs: - test-jdk17: - description: "Also run tests on JDK 17 (compatibility check)" - type: boolean - required: false - default: true merge_group: permissions: @@ -43,10 +37,14 @@ permissions: jobs: java-sdk: - name: "Java SDK Tests" + name: "Java SDK Tests (JDK ${{ matrix.test-jdk }})" if: github.event.repository.fork == false runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + test-jdk: ["25", "17"] defaults: run: shell: bash @@ -66,14 +64,18 @@ jobs: with: node-version: 22 - - uses: ./.github/actions/setup-copilot - id: setup-copilot + - name: Build SDK and set up test harness + run: mvn test-compile - - name: Install test harness dependencies - working-directory: ./test/harness - run: npm ci --ignore-scripts + - name: Verify Javadoc generation + if: matrix.test-jdk == '25' + run: mvn javadoc:javadoc -q + + - name: Verify CLI works + run: node target/copilot-sdk/nodejs/node_modules/@github/copilot/index.js --version - name: Run spotless check + if: matrix.test-jdk == '25' run: | mvn spotless:check if [ $? -ne 0 ]; then @@ -82,18 +84,30 @@ jobs: fi echo "✅ spotless:check passed" - - name: Verify Javadoc generation - run: mvn compile javadoc:javadoc -q + - name: Run Java SDK tests (JDK 25) + if: matrix.test-jdk == '25' + env: + CI: "true" + run: mvn verify -Dskip.test.harness=true + + - name: Switch to JDK 17 + if: matrix.test-jdk == '17' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: "17" + distribution: "microsoft" - - name: Run Java SDK tests + - name: Run Java SDK tests (JDK 17, no recompilation) + if: matrix.test-jdk == '17' env: CI: "true" - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} - run: mvn verify + run: | + echo "Running tests against JDK 25-built classes using JDK 17 runtime..." + java -version + mvn antrun:run@print-test-jdk-banner surefire:test -Denforcer.skip=true -DtestExecutionAgentArgs= - name: Upload test results for site generation - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.test-jdk == '25' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-for-site @@ -104,12 +118,12 @@ jobs: retention-days: 1 - name: Generate JaCoCo badge - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.test-jdk == '25' 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.test-jdk == '25' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7 with: commit-message: "Update Java JaCoCo coverage badge" @@ -119,43 +133,18 @@ jobs: add-paths: .github/badges/ delete-branch: true - - name: Generate Test Report Summary JDK 25 + - name: Generate Test Report Summary if: always() uses: ./.github/actions/java-test-report with: - title: "Copilot Java SDK :: Test Results JDK 25" + title: "Copilot Java SDK :: Test Results JDK ${{ matrix.test-jdk }}" - name: Upload test results on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: java-test-results + name: java-test-results-jdk-${{ matrix.test-jdk }} path: | java/target/surefire-reports/ java/target/surefire-reports-isolated/ retention-days: 7 - - - name: Switch to JDK 17 - if: inputs.test-jdk17 == true || inputs.test-jdk17 == null - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 - with: - java-version: "17" - distribution: "microsoft" - - - name: Re-run tests on JDK 17 (no recompilation) - if: inputs.test-jdk17 == true || inputs.test-jdk17 == null - env: - CI: "true" - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} - run: | - echo "Running tests against JDK 25-built classes using JDK 17 runtime..." - java -version - mvn antrun:run@print-test-jdk-banner surefire:test -Denforcer.skip=true -DtestExecutionAgentArgs= - - - name: Generate Test Report Summary JDK 17 - if: always() && (inputs.test-jdk17 == true || inputs.test-jdk17 == null) - uses: ./.github/actions/java-test-report - with: - title: "Copilot Java SDK :: Test Results JDK 17" - show-coverage: "false" From 293bece7923c8c22a01dbc2420aa983da971f604 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 12:41:16 -0700 Subject: [PATCH 16/18] On branch edburns/review-brunoborges-pr-1478 modified: .github/workflows/java-sdk-tests.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now the JDK 17 run: 1. `jacoco:prepare-agent@wire-up-coverage-instrumentation` — generates the JaCoCo agent arg compatible with JDK 17 and sets `testExecutionAgentArgs` 2. `antrun:run@print-test-jdk-banner` — prints the JDK banner 3. `surefire:test` — runs pre-compiled tests with the JaCoCo agent attached 4. `jacoco:report@build-coverage-report-from-tests` — generates the HTML/XML/CSV reports The `-DtestExecutionAgentArgs=` override is removed so JaCoCo's prepared value flows through to Surefire's ``. The test report action already reads from the default jacoco.xml path, so the coverage section will appear in both reports automatically. --- .github/workflows/java-sdk-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index abe38103f..2518aacca 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -104,7 +104,7 @@ jobs: run: | echo "Running tests against JDK 25-built classes using JDK 17 runtime..." java -version - mvn antrun:run@print-test-jdk-banner surefire:test -Denforcer.skip=true -DtestExecutionAgentArgs= + mvn jacoco:prepare-agent@wire-up-coverage-instrumentation antrun:run@print-test-jdk-banner surefire:test jacoco:report@build-coverage-report-from-tests -Denforcer.skip=true - name: Upload test results for site generation if: success() && github.ref == 'refs/heads/main' && matrix.test-jdk == '25' From 159e8c920725fb3a53a1e4c2c7436f030bb16e4c Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 13:39:26 -0700 Subject: [PATCH 17/18] On branch edburns/review-brunoborges-pr-1478 Address copilot review comments. modified: .github/actions/java-test-report/action.yml - Add failsafe tests to the report. modified: .github/workflows/java-sdk-tests.yml - Ensure failsafe tests are invoked in the 17 case. modified: java/README.md - Fix spelling error. - Fix artifact version error. - Fix 17 invocation. --- .github/actions/java-test-report/action.yml | 2 +- .github/workflows/java-sdk-tests.yml | 2 +- java/README.md | 12 +++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/actions/java-test-report/action.yml b/.github/actions/java-test-report/action.yml index a7170fd62..7d610e6b6 100644 --- a/.github/actions/java-test-report/action.yml +++ b/.github/actions/java-test-report/action.yml @@ -4,7 +4,7 @@ inputs: report-path: description: "Path to the test report XML files (glob pattern)" required: false - default: "java/target/surefire-reports*/TEST-*.xml" + default: "java/target/{surefire-reports*,failsafe-reports}/TEST-*.xml" jacoco-path: description: "Path to the JaCoCo XML report" required: false diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index 2518aacca..ccd030558 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -104,7 +104,7 @@ jobs: run: | echo "Running tests against JDK 25-built classes using JDK 17 runtime..." java -version - mvn jacoco:prepare-agent@wire-up-coverage-instrumentation antrun:run@print-test-jdk-banner surefire:test jacoco:report@build-coverage-report-from-tests -Denforcer.skip=true + mvn jacoco:prepare-agent@wire-up-coverage-instrumentation antrun:run@print-test-jdk-banner surefire:test failsafe:integration-test failsafe:verify jacoco:report@build-coverage-report-from-tests -Denforcer.skip=true - name: Upload test results for site generation if: success() && github.ref == 'refs/heads/main' && matrix.test-jdk == '25' diff --git a/java/README.md b/java/README.md index 53d8114cf..46c01635a 100644 --- a/java/README.md +++ b/java/README.md @@ -21,7 +21,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Runtime requirements -- Java 17 or later. **JDK 25 recommended**. The distributed jar is a multi-release jar (MR-JAR) and is copiled on JDK 25 with `maven.compiler.release` set to 17. This means, when run on JDK 25 and later, the SDK automatically uses virtual threads for its default internal executor. +- Java 17 or later. **JDK 25 recommended**. The distributed jar is a multi-release jar (MR-JAR) and is compiled on JDK 25 with `maven.compiler.release` set to 17. This means, when run on JDK 25 and later, the SDK automatically uses virtual threads for its default internal executor. - GitHub Copilot CLI 1.0.55-5. or later installed and in `PATH` (or provide custom `cliPath`) ### Maven @@ -30,10 +30,16 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A com.github copilot-sdk-java - 1.0.0-beta-java.4 + 1.0.0-beta-10-java.0 ``` +### Gradle + +```groovy +implementation 'com.github:copilot-sdk-java:1.0.0-beta-10-java.0' + + #### Snapshot Builds Snapshot builds of the next development version are published to Maven Central Snapshots. To use them, add the repository and update the dependency version in your `pom.xml`: @@ -168,7 +174,7 @@ mvn clean verify # Set your paths for JDK 17 # Run the JDK 25 built jar with JDK 17 JVM for tests. Do not re-compile the jar. -mvn antrun:run@print-test-jdk-banner surefire:test -Denforcer.skip=true -DtestExecutionAgentArgs= +mvn jacoco:prepare-agent@wire-up-coverage-instrumentation antrun:run@print-test-jdk-banner surefire:test failsafe:integration-test failsafe:verify jacoco:report@build-coverage-report-from-tests -Denforcer.skip=true ``` The tests require the official [copilot-sdk](https://github.com/github/copilot-sdk) test harness, which is automatically cloned during build. From 0f2a97f459910444b8867082248839d23318cba2 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 14:24:23 -0700 Subject: [PATCH 18/18] Ensure the Jar is produced --- .github/workflows/java-sdk-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index ccd030558..ef8fcc05e 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -65,7 +65,7 @@ jobs: node-version: 22 - name: Build SDK and set up test harness - run: mvn test-compile + run: mvn test-compile jar:jar - name: Verify Javadoc generation if: matrix.test-jdk == '25'