+ * 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