+ * Threading: On Java 21+, the SDK automatically uses virtual threads for + * its internal I/O threads (JSON-RPC reader, CLI stderr forwarding). On Java + * 17–20, standard platform threads are used. The + * {@link java.util.concurrent.ScheduledExecutorService} used for + * {@code sendAndWait} timeouts always uses platform threads because the JDK + * does not provide a virtual-thread-based scheduled executor. + *
* Example usage: * *
{@code
diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java
index 23b1b5368..f35353e4c 100644
--- a/src/main/java/com/github/copilot/sdk/CopilotSession.java
+++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java
@@ -89,6 +89,11 @@
* session data on disk — the conversation can be resumed later via
* {@link CopilotClient#resumeSession}. To permanently delete session data, use
* {@link CopilotClient#deleteSession}.
+ *
+ * Threading: The {@link java.util.concurrent.ScheduledExecutorService}
+ * used for {@code sendAndWait} timeouts always uses platform threads regardless
+ * of the Java version, because the JDK does not provide a virtual-thread-based
+ * scheduled executor.
*
*
Example Usage
*
diff --git a/src/main/java/com/github/copilot/sdk/JsonRpcClient.java b/src/main/java/com/github/copilot/sdk/JsonRpcClient.java
index 66ab1726d..4d16b02d3 100644
--- a/src/main/java/com/github/copilot/sdk/JsonRpcClient.java
+++ b/src/main/java/com/github/copilot/sdk/JsonRpcClient.java
@@ -15,7 +15,6 @@
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiConsumer;
import java.util.logging.Level;
@@ -57,11 +56,7 @@ private JsonRpcClient(InputStream inputStream, OutputStream outputStream, Socket
this.outputStream = outputStream;
this.socket = socket;
this.process = process;
- this.readerExecutor = Executors.newSingleThreadExecutor(r -> {
- Thread t = new Thread(r, "jsonrpc-reader");
- t.setDaemon(true);
- return t;
- });
+ this.readerExecutor = ThreadFactoryProvider.newSingleThreadExecutor("jsonrpc-reader");
startReader();
}
diff --git a/src/main/java/com/github/copilot/sdk/ThreadFactoryProvider.java b/src/main/java/com/github/copilot/sdk/ThreadFactoryProvider.java
new file mode 100644
index 000000000..2f864d921
--- /dev/null
+++ b/src/main/java/com/github/copilot/sdk/ThreadFactoryProvider.java
@@ -0,0 +1,68 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot.sdk;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Provides thread factories for the SDK's internal thread creation.
+ *
+ * On Java 17, this class returns standard platform-thread factories. On Java
+ * 21+, the multi-release JAR overlay replaces this class with one that returns
+ * virtual-thread factories, giving the SDK lightweight threads for its
+ * I/O-bound JSON-RPC communication without any user configuration.
+ *
+ * The {@link java.util.concurrent.ScheduledExecutorService} used for
+ * {@code sendAndWait} timeouts in {@link CopilotSession} is not
+ * affected, because the JDK offers no virtual-thread-based scheduled executor.
+ *
+ * @since 0.2.2-java.1
+ */
+final class ThreadFactoryProvider {
+
+ private ThreadFactoryProvider() {
+ }
+
+ /**
+ * Creates a new daemon thread with the given name and runnable.
+ *
+ * @param runnable
+ * the task to run
+ * @param name
+ * the thread name for debuggability
+ * @return the new (unstarted) thread
+ */
+ static Thread newThread(Runnable runnable, String name) {
+ Thread t = new Thread(runnable, name);
+ t.setDaemon(true);
+ return t;
+ }
+
+ /**
+ * Creates a single-thread executor suitable for the JSON-RPC reader loop.
+ *
+ * @param name
+ * the thread name for debuggability
+ * @return a single-thread {@link ExecutorService}
+ */
+ static ExecutorService newSingleThreadExecutor(String name) {
+ return Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, name);
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ /**
+ * Returns {@code true} when this class uses virtual threads (Java 21+
+ * multi-release overlay), {@code false} for platform threads.
+ *
+ * @return whether virtual threads are in use
+ */
+ static boolean isVirtualThreads() {
+ return false;
+ }
+}
diff --git a/src/main/java21/com/github/copilot/sdk/ThreadFactoryProvider.java b/src/main/java21/com/github/copilot/sdk/ThreadFactoryProvider.java
new file mode 100644
index 000000000..6fef02aca
--- /dev/null
+++ b/src/main/java21/com/github/copilot/sdk/ThreadFactoryProvider.java
@@ -0,0 +1,58 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+package com.github.copilot.sdk;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Java 21+ override that uses virtual threads for the SDK's internal thread
+ * creation.
+ *
+ * This class is placed under {@code META-INF/versions/21/} in the multi-release
+ * JAR and replaces the baseline {@code ThreadFactoryProvider} when running on
+ * Java 21 or later.
+ *
+ * @since 0.2.2-java.1
+ */
+final class ThreadFactoryProvider {
+
+ private ThreadFactoryProvider() {
+ }
+
+ /**
+ * Creates a new virtual thread with the given name and runnable.
+ *
+ * @param runnable
+ * the task to run
+ * @param name
+ * the thread name for debuggability
+ * @return the new (unstarted) virtual thread
+ */
+ static Thread newThread(Runnable runnable, String name) {
+ return Thread.ofVirtual().name(name).unstarted(runnable);
+ }
+
+ /**
+ * Creates a single-thread executor backed by a virtual-thread factory for the
+ * JSON-RPC reader loop.
+ *
+ * @param name
+ * the thread name for debuggability
+ * @return a single-thread virtual-thread-backed {@link ExecutorService}
+ */
+ static ExecutorService newSingleThreadExecutor(String name) {
+ return Executors.newSingleThreadExecutor(Thread.ofVirtual().name(name).factory());
+ }
+
+ /**
+ * Returns {@code true} — this is the virtual-thread overlay.
+ *
+ * @return {@code true}
+ */
+ static boolean isVirtualThreads() {
+ return true;
+ }
+}
diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md
index 5ae5c8f94..9b1484750 100644
--- a/src/site/markdown/advanced.md
+++ b/src/site/markdown/advanced.md
@@ -54,6 +54,7 @@ This guide covers advanced scenarios for extending and customizing your Copilot
- [Session Capabilities](#Session_Capabilities)
- [Outgoing Elicitation via session.getUi()](#Outgoing_Elicitation_via_session.getUi)
- [Getting Session Metadata by ID](#Getting_Session_Metadata_by_ID)
+- [Virtual Threads (Java 21+)](#Virtual_Threads_Java_21)
---
@@ -1237,6 +1238,32 @@ This is more efficient than `listSessions()` when you already know the session I
---
+## Virtual Threads (Java 21+)
+
+When running on **Java 21 or later**, the SDK automatically uses [virtual threads (JEP 444)](https://openjdk.org/jeps/444) for its internal I/O threads. This is implemented via a [Multi-Release JAR (JEP 238)](https://openjdk.org/jeps/238) — no configuration or code changes are required.
+
+### What Uses Virtual Threads
+
+| Component | Thread name | Java 17–20 | Java 21+ |
+|-----------|------------|-------------|----------|
+| JSON-RPC reader loop | `jsonrpc-reader` | Platform (daemon) | Virtual |
+| CLI stderr forwarding | `cli-stderr-reader` | Platform (daemon) | Virtual |
+| `sendAndWait` timeouts | `sendAndWait-timeout` | Platform (daemon) | Platform (daemon) |
+
+The `sendAndWait` timeout scheduler always uses platform threads because the JDK does not provide a virtual-thread-based `ScheduledExecutorService`.
+
+### Performance Implications
+
+Virtual threads are lightweight and scheduled by the JVM on a shared `ForkJoinPool` carrier pool. For the I/O-bound JSON-RPC communication this SDK performs, virtual threads reduce memory footprint and improve scalability when many concurrent sessions are active.
+
+### How It Works
+
+The SDK JAR includes `Multi-Release: true` in its manifest. On Java 21+, the JVM loads the `ThreadFactoryProvider` class from `META-INF/versions/21/`, which uses `Thread.ofVirtual()`. On earlier JVMs, the baseline class under the main class path is loaded, which creates standard platform threads.
+
+### Verifying Virtual Thread Usage
+
+Thread names are preserved for debuggability regardless of thread type. On Java 21+, a thread dump will show `jsonrpc-reader` and `cli-stderr-reader` as virtual threads rather than platform threads. You can verify this via `jcmd Thread.dump_to_file -format=json ` or your IDE's thread inspector.
+
## Next Steps
- 📖 **[Documentation](documentation.html)** - Core concepts, events, streaming, models, tool filtering, reasoning effort
diff --git a/src/site/markdown/getting-started.md b/src/site/markdown/getting-started.md
index 39bf43844..913ae7ad2 100644
--- a/src/site/markdown/getting-started.md
+++ b/src/site/markdown/getting-started.md
@@ -18,7 +18,7 @@ Copilot: In Tokyo it's 75°F and sunny. Great day to be outside!
Before you begin, make sure you have:
-- **Java 17+** installed
+- **Java 17+** installed (**Java 21+ recommended** for automatic [virtual thread](https://openjdk.org/jeps/444) support)
- **GitHub Copilot CLI** installed and authenticated ([Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli))
Verify the CLI is working:
diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md
index b599484d9..1abd0efc1 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 (**Java 21+ recommended** for automatic [virtual thread](https://openjdk.org/jeps/444) support)
- 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/ThreadFactoryProviderTest.java b/src/test/java/com/github/copilot/sdk/ThreadFactoryProviderTest.java
new file mode 100644
index 000000000..3ea57ce96
--- /dev/null
+++ b/src/test/java/com/github/copilot/sdk/ThreadFactoryProviderTest.java
@@ -0,0 +1,65 @@
+/*---------------------------------------------------------------------------------------------
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link ThreadFactoryProvider}, verifying that the factory methods
+ * produce working threads and executors regardless of the Java version.
+ */
+class ThreadFactoryProviderTest {
+
+ @Test
+ void newThreadCreatesNamedThread() {
+ var ran = new AtomicReference();
+ Thread t = ThreadFactoryProvider.newThread(() -> ran.set(Thread.currentThread().getName()), "test-thread");
+ assertNotNull(t);
+ assertEquals("test-thread", t.getName());
+ t.start();
+ try {
+ t.join(5000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ assertEquals("test-thread", ran.get());
+ }
+
+ @Test
+ void newSingleThreadExecutorRunsTask() throws Exception {
+ ExecutorService executor = ThreadFactoryProvider.newSingleThreadExecutor("test-executor");
+ try {
+ var ran = new AtomicReference(false);
+ executor.submit(() -> ran.set(true)).get(5, TimeUnit.SECONDS);
+ assertTrue(ran.get());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void isVirtualThreadsReturnsBoolean() {
+ // Unit tests run against exploded classes rather than the packaged
+ // multi-release JAR, so Java 21+ may still load the base implementation
+ // and report false here. Verify only behavior that does not depend on
+ // multi-release class selection.
+ boolean result = ThreadFactoryProvider.isVirtualThreads();
+ int javaVersion = Runtime.version().feature();
+ if (javaVersion < 21) {
+ assertFalse(result, "Expected platform threads on Java < 21");
+ } else if (result) {
+ assertTrue(javaVersion >= 21, "Virtual threads are only supported on Java 21+");
+ }
+ }
+}