+ * 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;
+
+ InternalExecutorProvider(Executor userProvided) {
+ if (userProvided != null) {
+ this.executor = userProvided;
+ } else {
+ this.executor = ForkJoinPool.commonPool();
+ }
+ }
+
+ Executor get() {
+ return 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/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
+ * 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/InternalExecutorProvider.java b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java
new file mode 100644
index 000000000..10878bb0c
--- /dev/null
+++ b/java/src/main/java25/com/github/copilot/InternalExecutorProvider.java
@@ -0,0 +1,65 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot;
+
+import java.util.concurrent.Executor;
+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;
+ private final boolean owned;
+
+ InternalExecutorProvider(Executor userProvided) {
+ if (userProvided != null) {
+ this.executor = userProvided;
+ this.owned = false;
+ } else {
+ this.executor = Executors.newVirtualThreadPerTaskExecutor();
+ this.owned = true;
+ }
+ }
+
+ Executor get() {
+ return executor;
+ }
+
+ 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/InternalExecutorProviderIT.java b/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java
new file mode 100644
index 000000000..1cc6b482e
--- /dev/null
+++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderIT.java
@@ -0,0 +1,108 @@
+/*---------------------------------------------------------------------------------------------
+ * 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
+ * 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
new file mode 100644
index 000000000..f1d854cb5
--- /dev/null
+++ b/java/src/test/java/com/github/copilot/InternalExecutorProviderTest.java
@@ -0,0 +1,55 @@
+/*---------------------------------------------------------------------------------------------
+ * 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 java.lang.reflect.Modifier;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ForkJoinPool;
+
+import org.junit.jupiter.api.Test;
+
+import com.github.copilot.rpc.CopilotClientOptions;
+
+class InternalExecutorProviderTest {
+
+ @Test
+ 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()));
+ }
+
+ @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();
+ }
+ }
+}