Skip to content

Use virtual threads by default on Java 21+ #56

Description

@brunoborges

Summary

When running on Java 21+, the SDK should default to using virtual threads instead of platform threads for its internal thread pools and thread creation.

Motivation

Virtual threads (JEP 444, finalized in Java 21) are lightweight threads that can significantly reduce resource usage for I/O-bound workloads like the JSON-RPC communication this SDK performs. Since the SDK targets Java 17+, we can use Multi-Release JARs (JEP 238) to provide a Java 21+ implementation that uses virtual threads while keeping the Java 17 baseline implementation with platform threads.

Current thread usage

The SDK currently creates platform threads in several places:

  • JsonRpcClient — Executors.newSingleThreadExecutor() for the JSON-RPC reader loop, plus a Thread(r, "jsonrpc-reader") factory
  • CopilotSession — ScheduledExecutorService for sendAndWait timeouts, plus Thread(r, "sendAndWait-timeout") factory
  • CliServerManager — Thread for stderr forwarding

Proposed approach

  1. Create a ThreadFactoryProvider (or similar) abstraction in src/main/java/ that returns standard platform-thread factories (the Java 17 baseline)
  2. Create a Java 21+ override in src/main/java21/ that returns virtual-thread factories (Thread.ofVirtual().factory())
  3. Configure the Maven build to produce a multi-release JAR with the Java 21 classes under META-INF/versions/21/
  4. Replace direct new Thread() and Executors.newSingleThreadExecutor() calls with the factory abstraction

Build conditionality

The second maven-compiler-plugin execution (compiling src/main/java21/ with --release 21 and multiReleaseOutput=true) should be gated by a <jdk>[21,)</jdk> Maven profile so the project still builds cleanly on JDK 17 — the JAR just won't include the MR overlay. CI and release builds should use JDK 21+ to produce the full multi-release JAR.

ScheduledThreadPoolExecutor stays with platform threads

The JDK has no virtual-thread-based ScheduledExecutorService. The ScheduledThreadPoolExecutor in CopilotSession (used for sendAndWait timeouts) should remain with platform threads and is out of scope for this issue.

Spotless coverage for src/main/java21/

The Spotless plugin config needs an explicit include for src/main/java21/**/*.java so the Java 21 override class gets formatted consistently.

Documentation

Ensure that the following documentation is updated to clearly describe virtual threads support when running on Java 21+:

  • README.md — Add a section or note about automatic virtual thread usage on Java 21+, and any configuration/opt-out if applicable
  • Quick Start guide (src/site/markdown/) — Mention virtual threads as a benefit of running on Java 21+ in the getting started flow
  • Examples (e.g., jbang-example.java) — Ensure examples work seamlessly on both Java 17 (platform threads) and Java 21+ (virtual threads), and add comments or notes highlighting the behavior difference
  • Site docs (src/site/markdown/) — Add a dedicated section or page covering virtual threads support: what it means for users, performance implications, and any caveats (e.g., ScheduledExecutorService remaining on platform threads)
  • Javadoc — Document thread behavior on relevant public APIs (e.g., CopilotClient, CopilotSession) so users understand which threads are used at runtime

Notes

  • The ScheduledExecutorService in CopilotSession does not have a virtual-thread equivalent in the JDK — it may need to stay as-is or use an alternative scheduling approach
  • Virtual threads should use the default ForkJoinPool carrier; no custom carrier pool is needed
  • Ensure thread names are preserved for debuggability (Thread.ofVirtual().name("jsonrpc-reader"))

Activity

  1. brunoborges commented on Apr 6, 2026

    @brunoborges
    CollaboratorAuthor

    @edburns let me know what you think before I assign it to @copilot

  2. edburns commented on Apr 6, 2026

    @edburns
    Collaborator

    @brunoborges this seems a lot more involved than what we already did for Virtual Threads here: #40 . I am worried about architectural integrity with this different approach.

    As the DRI, I'm not comfortable proceeding with this approach just yet. Sure you can assign it to copilot, but I can't consent to merging the work it eventually does until I think about this some more.

  3. brunoborges commented on Apr 6, 2026

    @brunoborges
    CollaboratorAuthor

    The goal here is to allow Copilot SDK to default to VTs when running on JDK 21+ without requiring manual user configuration. They can still revert to non-VT in JDK 21+ if they want.

    I thought you did want to have VT by default, and why you wanted to push the SDK to a 21+ baseline.

    The proposal here is the perfect balance between JDK 17 baseline support, while defaulting to VT when the SDK runs on JDK 21+.

  4. edburns commented on Apr 12, 2026

    @edburns
    Collaborator

    Hi @brunoborges , I have performed my analysis on the necessity of fixing #56 with #60 . Below you can find the receipts. My conclusion is that accepting these changes is not worth the added complexity.

    I am open to further debate on this matter, but if you want to re-open the debate, I request we have a human discussion about it before spending any tokens.

    Receipts

    13:26 Prompt

    In this session, I aim to review PR #60 .

    My first question: how PR 60, which ostensibly aims to "Use virtual threads by default on Java 21+ via Multi-Release JAR" relates to

    During the dev work for PR 40, Copilot told me that these changes were sufficient to provide Virtual Threads support. If that is true, what is the point of PR 60?

    13:35 Response

    Fetched 2 resources

    Read , lines 1 to 50

    Now let me look at the actual code in the workspace to understand the current state and the PR 60 branch changes.

    Explore virtual thread code

    Read

    Searched for files matching **/ThreadFactoryProvider.java, 2 matches

    Searched for files matching **/JsonRpcClient.java, 1 match

    Searched for files matching **/CliServerManager.java, 1 match

    Searched for regex virtual thread|virtual threads (**/README.md), 5 results

    Searched for files matching **/CopilotClientOptions.java, 1 match

    Read , lines 1 to 50

    Read , lines 1 to 50

    Read , lines 1 to 150

    Read , lines 1 to 150

    Read , lines 1 to 100

    Read , lines 140 to 160

    Read

    Read , lines 1 to 60

    Searched for text startReader (**/src/main/java/com/github/copilot/sdk/JsonRpcClient.java), 2 results

    Searched for text startStderrReader (**/src/main/java/com/github/copilot/sdk/CliServerManager.java), 2 results

    Searched for text setExecutor (**/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java), 1 result

    Read , lines 179 to 220

    Read , lines 174 to 200

    Read , lines 260 to 300

    Here's my analysis of how the two PRs relate and whether PR 60 is justified:

    What each PR covers

    PR 40 wired a user-provided Executor through all CompletableFuture.*Async call sites — tool handlers, permission handlers, session operations, client start/stop. Users opt in with one line:

    var options = new CopilotClientOptions()
        .setExecutor(Executors.newVirtualThreadPerTaskExecutor());

    PR 60 adds a Multi-Release JAR (ThreadFactoryProvider) that automatically switches two internal SDK daemon threads to virtual threads on Java 21+:

    • jsonrpc-reader thread (in JsonRpcClient)
    • cli-stderr-reader thread (in CliServerManager)

    The contradiction

    During PR 40, the Copilot analysis explicitly concluded these internal threads would not benefit from being virtual:

    "The reader thread does blocking I/O on ProcessInputStream — making it virtual wouldn't help (it's pinned on a native read syscall anyway)."
    "The timeout scheduler must be a ScheduledExecutorService; virtual threads don't improve timer-wheel behaviour."
    "No API change needed here."

    PR 60 does exactly what PR 40's analysis said was unnecessary — making the reader and stderr threads virtual.

    Is PR 60 justified?

    Marginal benefit at best. The jsonrpc-reader and cli-stderr-reader threads block on native process I/O (InputStream.read() on a pipe). On JDK 21–23, virtual threads are pinned during native syscalls, so they behave identically to platform threads for this workload. On JDK 24+ (JEP 491), synchronized pinning is fixed, but these threads aren't contending on monitors — they're blocked on I/O.

    The only real argument for PR 60 is resource overhead: virtual threads are cheaper than platform threads (no 1MB stack reservation). But the SDK only creates 2 daemon threads per client instance, so the savings are negligible.

    Meanwhile, the meaningful virtual thread benefit — running many concurrent CompletableFuture tasks without pool starvation — is already fully covered by PR 40's setExecutor().

    Summary

    Concern PR 40 PR 60
    CompletableFuture.*Async work (tool/permission/session) Covered via setExecutor() Not addressed
    Internal I/O daemon threads (2 threads) Not addressed (analysis said unnecessary) Covered via MR-JAR
    User action required Yes — one line of code None — automatic
    Real-world performance impact High (fixes pool starvation) Negligible (2 threads doing pinned native I/O)

    Your Copilot was essentially right during PR 40: the setExecutor() API is the meaningful virtual thread enabler. PR 60 adds complexity (MR-JAR build, dual source trees, ThreadFactoryProvider abstraction) for a marginal benefit on exactly the threads that the PR 40 analysis said wouldn't benefit. You should weigh whether the added build/maintenance complexity is worth the "zero-config" story for 2 internal daemon threads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions