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

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

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

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

+ * If not set (or set to {@code null}), the SDK uses its default executor: + * virtual threads on JDK 25+, {@code ForkJoinPool.commonPool()} on older JDKs. * * @param executor - * the executor to use, or {@code null} for the default + * the executor to use, or {@code null} for SDK defaults * @return this options instance for fluent chaining */ public CopilotClientOptions setExecutor(Executor executor) { diff --git a/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java new file mode 100644 index 000000000..257d0f61e --- /dev/null +++ b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.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 InternalExecutorProvider { + + private InternalExecutorProvider() { + } + + 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/InternalExecutorProviderTest.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java new file mode 100644 index 000000000..7ec4a420d --- /dev/null +++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * 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.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; + +import com.github.copilot.rpc.CopilotClientOptions; + +class InternalExecutorProviderTest { + + @Test + void baseProviderUsesCommonPoolWithoutOwnership() { + Executor executor = InternalExecutorProvider.create(); + + assertSame(ForkJoinPool.commonPool(), executor); + assertFalse(InternalExecutorProvider.isOwned(executor)); + assertFalse(Modifier.isPublic(InternalExecutorProvider.class.getModifiers())); + } + + @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/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); + 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); + } + } + + @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 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"); + 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(); + } +}