diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 29fd1fd119..62a3cd07c7 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -51,7 +51,7 @@ jobs: node-version: 22 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: - java-version: "17" + java-version: "25" distribution: "microsoft" cache: "maven" diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index cc612389b7..4eafdff0a6 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -50,10 +50,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 @@ -253,4 +253,3 @@ jobs: -f publish_as_latest=true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index c10af9fd0e..12a6e5ea04 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -26,10 +26,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/run-smoke-test.yml b/.github/workflows/run-smoke-test.yml index 8d0feac0c1..9640c13af3 100644 --- a/.github/workflows/run-smoke-test.yml +++ b/.github/workflows/run-smoke-test.yml @@ -94,10 +94,7 @@ jobs: **Critical override — do NOT run the jar:** Stop after the `mvn --no-snapshot-updates clean package` build succeeds. Do NOT execute Step 4 (java -jar) or Step 5 (verify exit code) from the prompt. The workflow will run the jar in a separate deterministic step to guarantee the exit code propagates correctly. - **Critical override — enable Virtual Threads for JDK 25:** After creating the Java source file from the README "Quick Start" section but BEFORE building, you must modify the source file to enable virtual thread support. The Quick Start code contains inline comments that start with `// JDK 25+:` — these are instructions. Find every such comment and follow what it says (comment out lines it says to comment out, uncomment lines it says to uncomment). Add any imports required by the newly uncommented code (e.g. `java.util.concurrent.Executors`). - Also set `maven.compiler.source` and `maven.compiler.target` to `25` in the `pom.xml`. - - Follow steps 1-3 only: create the `smoke-test/` directory, create `pom.xml` and the Java source file exactly as specified, apply the JDK 25 virtual thread modifications described above, and build with `mvn --no-snapshot-updates clean package` (no SNAPSHOT updates and without `-U`). + Follow steps 1-3 only: create the `smoke-test/` directory, create `pom.xml` and the Java source file exactly as specified, and build with `mvn --no-snapshot-updates clean package` (no SNAPSHOT updates and without `-U`). Do not modify the Quick Start source for JDK 25; the SDK automatically selects its JDK 25 defaults from the multi-release JAR. If any step fails, exit with a non-zero exit code. Do not silently fix errors. PROMPT_EOF diff --git a/.vscode/mcp.json b/.vscode/mcp.json deleted file mode 100644 index 6699af5648..0000000000 --- a/.vscode/mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "servers": { - "github-agentic-workflows": { - "command": "gh", - "args": [ - "aw", - "mcp-server" - ], - "cwd": "${workspaceFolder}" - } - } -} \ No newline at end of file diff --git a/README.md b/README.md index 37bb2d659b..ef3c3052bb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,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 @@ -70,23 +70,16 @@ implementation 'com.github:copilot-sdk-java:1.0.0-beta-java.4' import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.generated.AssistantMessageEvent; import com.github.copilot.sdk.generated.SessionUsageInfoEvent; -import com.github.copilot.sdk.json.CopilotClientOptions; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.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/pom.xml b/pom.xml index be9e92a93a..bcae4c7b04 100644 --- a/pom.xml +++ b/pom.xml @@ -447,6 +447,10 @@ ${project.build.directory}/jacoco-test-results/sdk-tests.exec ${project.reporting.outputDirectory}/jacoco-coverage + + + META-INF/versions/**/*.class + @@ -693,6 +697,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/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 4d0770319a..8e310db302 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -14,6 +14,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -78,6 +79,8 @@ public final class CopilotClient implements AutoCloseable { public static final int AUTOCLOSEABLE_TIMEOUT_SECONDS = 10; private static final int FORCE_KILL_TIMEOUT_SECONDS = 10; private final CopilotClientOptions options; + private final Executor executor; + private final ExecutorService ownedExecutor; private final CliServerManager serverManager; private final LifecycleEventManager lifecycleManager = new LifecycleEventManager(); private final Map sessions = new ConcurrentHashMap<>(); @@ -153,6 +156,11 @@ public CopilotClient(CopilotClientOptions options) { this.optionsPort = null; } + Executor providedExecutor = this.options.getExecutor(); + this.executor = providedExecutor != null ? providedExecutor : DefaultExecutorProvider.INSTANCE.get(); + this.ownedExecutor = providedExecutor == null && this.executor instanceof ExecutorService executorService + ? executorService + : null; this.serverManager = new CliServerManager(this.options); this.serverManager.setConnectionToken(this.effectiveConnectionToken); } @@ -176,11 +184,9 @@ public CompletableFuture start() { private CompletableFuture startCore() { LOG.fine("Starting Copilot client"); - Executor exec = options.getExecutor(); + Executor exec = executor; try { - return exec != null - ? CompletableFuture.supplyAsync(this::startCoreBody, exec) - : CompletableFuture.supplyAsync(this::startCoreBody); + return CompletableFuture.supplyAsync(this::startCoreBody, exec); } catch (RejectedExecutionException e) { return CompletableFuture.failedFuture(e); } @@ -209,8 +215,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 +313,7 @@ private static boolean isUnsupportedConnectMethod(JsonRpcException ex) { */ public CompletableFuture stop() { var closeFutures = new ArrayList>(); - Executor exec = options.getExecutor(); + Executor exec = executor; for (CopilotSession session : new ArrayList<>(sessions.values())) { Runnable closeTask = () -> { @@ -320,9 +325,7 @@ public CompletableFuture stop() { }; CompletableFuture future; try { - future = exec != null - ? CompletableFuture.runAsync(closeTask, exec) - : CompletableFuture.runAsync(closeTask); + future = CompletableFuture.runAsync(closeTask, exec); } catch (RejectedExecutionException e) { LOG.log(Level.WARNING, "Executor rejected session close task; closing inline", e); closeTask.run(); @@ -344,7 +347,7 @@ public CompletableFuture stop() { public CompletableFuture forceStop() { disposed = true; sessions.clear(); - return cleanupConnection(); + return cleanupConnection().whenComplete((ignored, error) -> shutdownOwnedExecutor()); } private CompletableFuture cleanupConnection() { @@ -436,9 +439,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 +525,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, @@ -924,6 +923,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/src/main/java/com/github/copilot/sdk/DefaultExecutorProvider.java b/src/main/java/com/github/copilot/sdk/DefaultExecutorProvider.java new file mode 100644 index 0000000000..2691826e29 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/DefaultExecutorProvider.java @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; + +enum DefaultExecutorProvider { + INSTANCE; + + private final Executor executor = ForkJoinPool.commonPool(); + + Executor get() { + return executor; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index e4605ffe10..87e1bb70bf 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/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/src/main/java25/com/github/copilot/sdk/DefaultExecutorProvider.java b/src/main/java25/com/github/copilot/sdk/DefaultExecutorProvider.java new file mode 100644 index 0000000000..c429047f30 --- /dev/null +++ b/src/main/java25/com/github/copilot/sdk/DefaultExecutorProvider.java @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +enum DefaultExecutorProvider { + INSTANCE; + + private final Executor executor = Executors.newVirtualThreadPerTaskExecutor(); + + Executor get() { + return executor; + } +} diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index a097da69e4..ef6e2c1cd5 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -8,7 +8,7 @@ Welcome to the documentation for the **GitHub Copilot SDK for Java** — a Java ### Requirements -- Java 17 or later +- Java 17 or later. 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`) ### Installation diff --git a/src/test/java/com/github/copilot/sdk/DefaultExecutorProviderTest.java b/src/test/java/com/github/copilot/sdk/DefaultExecutorProviderTest.java new file mode 100644 index 0000000000..a7b4fab8e9 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/DefaultExecutorProviderTest.java @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +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.IOException; +import java.lang.reflect.Field; +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.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; + +class DefaultExecutorProviderTest { + + @Test + void testBaseImplementationReturnsForkJoinPool() { + Executor executor = DefaultExecutorProvider.INSTANCE.get(); + assertNotNull(executor); + assertEquals(ForkJoinPool.commonPool(), executor); + } + + @Test + void multiReleaseProviderUsesVirtualThreadsOnJdk25() throws Exception { + if (Runtime.version().feature() < 25) { + return; + } + + Path classes = Path.of("target", "classes"); + Path baseClass = classes.resolve("com/github/copilot/sdk/DefaultExecutorProvider.class"); + Path java25Class = classes.resolve("META-INF/versions/25/com/github/copilot/sdk/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.sdk.DefaultExecutorProvider", true, loader); + Field instanceField = provider.getDeclaredField("INSTANCE"); + instanceField.setAccessible(true); + Object instance = instanceField.get(null); + + Method getMethod = provider.getDeclaredMethod("get"); + getMethod.setAccessible(true); + Object result = getMethod.invoke(instance); + + assertNotNull(result, "JDK 25 multi-release provider must create a default executor"); + assertTrue(result instanceof Executor, "Default provider must return an Executor"); + + var executor = (Executor) result; + try { + var ranOnVirtualThread = new CompletableFuture(); + executor.execute(() -> ranOnVirtualThread.complete(isCurrentThreadVirtual())); + assertTrue(ranOnVirtualThread.get(10, TimeUnit.SECONDS), + "JDK 25 default executor must run tasks on virtual threads"); + } finally { + if (result instanceof ExecutorService executorService) { + executorService.shutdownNow(); + } + } + } + } finally { + Files.deleteIfExists(jar); + } + } + + private static void createProviderJar(Path jar, Path baseClass, Path java25Class) throws IOException { + var manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + attributes.putValue("Multi-Release", "true"); + + try (var out = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + addEntry(out, baseClass, "com/github/copilot/sdk/DefaultExecutorProvider.class"); + addEntry(out, java25Class, "META-INF/versions/25/com/github/copilot/sdk/DefaultExecutorProvider.class"); + } + } + + private static void addEntry(JarOutputStream out, Path source, String entryName) throws IOException { + out.putNextEntry(new JarEntry(entryName)); + Files.copy(source, out); + out.closeEntry(); + } + + private static boolean isCurrentThreadVirtual() { + try { + Method isVirtual = Thread.class.getMethod("isVirtual"); + return (Boolean) isVirtual.invoke(Thread.currentThread()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Thread.isVirtual() is not available", e); + } + } +}