Skip to content

Accept optional Executor on CopilotClientOptions to mitigate pool starvation#40

Merged
edburns merged 9 commits intomainfrom
edburns/dd-2758695-virtual-threads-accept-executor
Mar 31, 2026
Merged

Accept optional Executor on CopilotClientOptions to mitigate pool starvation#40
edburns merged 9 commits intomainfrom
edburns/dd-2758695-virtual-threads-accept-executor

Conversation

@edburns
Copy link
Copy Markdown
Collaborator

@edburns edburns commented Mar 30, 2026

Before the change?

  • All internal CompletableFuture.*Async calls used ForkJoinPool.commonPool(), which could become starved when multiple sessions blocked concurrently.
  • CopilotClientOptions had no way for callers to supply a custom thread pool.
  • RejectedExecutionException thrown synchronously by any async submission site would propagate to callers unexpectedly, potentially leaving JSON-RPC requests unanswered or skipping connection cleanup.
  • All reference-type setters in CopilotClientOptions silently accepted null, making the API error-prone.

After the change?

  • CopilotClientOptions has a new Executor field with fluent setExecutor(Executor) / getExecutor() accessors. When not set, the default ForkJoinPool.commonPool() behaviour is preserved.
  • Clone support for the new field is included.
  • The executor is wired through CopilotClient start/stop, RpcHandlerDispatcher (all 5 runAsync call sites via a private helper), and CopilotSession (executeToolAndRespondAsync / executePermissionAndRespondAsync).
  • All async submission sites now catch RejectedExecutionException and handle it context-sensitively:
    • CopilotClient.startCore() — returns CompletableFuture.failedFuture(e) so rejection surfaces through the returned future instead of throwing synchronously.
    • CopilotClient.stop() — falls back to closing each session inline so cleanup always completes even if the executor has been shut down.
    • RpcHandlerDispatcher.runAsync() — falls back to running the handler inline on the notification thread so the JSON-RPC request is never left unanswered.
    • CopilotSession.executeToolAndRespondAsync() and executePermissionAndRespondAsync() — fall back to running the task inline so pending tool/permission calls always receive a reply.
  • All reference-type setters in CopilotClientOptions now throw NullPointerException via Objects.requireNonNull when null is passed, making the API stricter and safer toward GA. Callers wishing to use the default (unset) value for an optional field should simply not call the setter. Affected setters: setCliArgs, setCliPath, setCliUrl, setCwd, setEnvironment, setExecutor, setGitHubToken, setGithubToken (deprecated), setLogLevel, setOnListModels, setTelemetry.
  • ExecutorWiringTest (6 tests) verifies the executor is used for client start, tool-call dispatch, permission dispatch, user-input dispatch, hooks dispatch, and client stop.
  • The multiple-sessions cookbook gains a new "Providing a custom Executor for parallel sessions" section with a pool-starvation explanation and a complete JBang example.
  • RpcHandlerDispatcherTest updated for the new 3-arg constructor (passes null).
  • CliServerManagerTest updated: the test that previously relied on setCliPath(null) to exercise the default-to-"copilot" code path now uses a default CopilotClientOptions() without calling setCliPath.

Pull request checklist

  • Tests for the changes have been added (for bug fixes / features)
  • Docs have been reviewed and added / updated if needed (for bug fixes / features)
  • mvn spotless:apply has been run to format the code
  • mvn clean verify passes locally

Does this introduce a breaking change?

  • Yes
  • No

Copilot AI review requested due to automatic review settings March 30, 2026 21:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for supplying a caller-provided Executor to route the SDK’s internal CompletableFuture.*Async work off ForkJoinPool.commonPool(), reducing starvation risk when running multiple blocking sessions concurrently.

Changes:

  • Introduces CopilotClientOptions.getExecutor() / setExecutor(Executor) (nullable) and includes it in clone().
  • Wires the executor through CopilotClient start/stop paths, RpcHandlerDispatcher, and CopilotSession tool/permission response execution.
  • Adds E2E coverage (ExecutorWiringTest) and documents the new option in the multiple-sessions cookbook.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java Adds nullable Executor option and clones it.
src/main/java/com/github/copilot/sdk/CopilotClient.java Uses provided executor for startCore() and stop() async work; passes executor into dispatcher and sessions.
src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java Adds executor-aware async dispatch helper for server-to-client handler execution.
src/main/java/com/github/copilot/sdk/CopilotSession.java Routes broadcast tool/permission response tasks through provided executor when present.
src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java New E2E tests asserting executor usage across key async dispatch paths.
src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java Updates dispatcher construction for new constructor signature.
src/site/markdown/cookbook/multiple-sessions.md Documents pool starvation and how to provide a custom executor.
Comments suppressed due to low confidence (1)

src/main/java/com/github/copilot/sdk/CopilotSession.java:789

  • Same as tool execution: submitting this permission-response task via CompletableFuture.runAsync(task, executor) can throw RejectedExecutionException synchronously if the provided executor rejects tasks. Consider handling submission failure to ensure the pending permission request is either answered (e.g., denied) or otherwise completed deterministically.
        if (executor != null) {
            CompletableFuture.runAsync(task, executor);
        } else {
            CompletableFuture.runAsync(task);
        }

@brunoborges
Copy link
Copy Markdown
Collaborator

@edburns should we throw an IAE if the user passes null?

@edburns
Copy link
Copy Markdown
Collaborator Author

edburns commented Mar 30, 2026

@brunoborges wrote:

should we throw an IAE if the user passes null?

We don't do that for other setters in CopilotClientOptions. I might say "yes" we should do that, but I'd like to understand why we would not do it for all other options before I fully commit to "yes" in the case of Executor.

@brunoborges
Copy link
Copy Markdown
Collaborator

@brunoborges wrote:

should we throw an IAE if the user passes null?

We don't do that for other setters in CopilotClientOptions. I might say "yes" we should do that, but I'd like to understand why we would not do it for all other options before I fully commit to "yes" in the case of Executor.

There was no deep thinking before. Making null-safe now leads us to a more stable and strict API, and towards GA.

What I mean is that this is perhaps now the moment to make the API safer.

@edburns
Copy link
Copy Markdown
Collaborator Author

edburns commented Mar 31, 2026

@brunoborges wrote:

There was no deep thinking before. Making null-safe now leads us to a more stable and strict API, and towards GA.

What I mean is that this is perhaps now the moment to make the API safer.

Of course. I'll take this as consent to add the null checks throughout, at least in the CopilotClientOptions class.

I'll take the remainder of the null checks as a separate issue.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 31, 2026

@brunoborges wrote:

There was no deep thinking before. Making null-safe now leads us to a more stable and strict API, and towards GA.
...

Done in commit 9098d5a. All reference-type setters in CopilotClientOptions now call Objects.requireNonNull and throw NullPointerException for null arguments: setCliArgs, setCliPath, setCliUrl, setCwd, setEnvironment, setExecutor, setGitHubToken, setGithubToken (deprecated), setLogLevel, setOnListModels, and setTelemetry. Javadoc "or null to…" wording removed from affected setters.

The one test that explicitly called setCliPath(null) (CliServerManagerTest.startCliServerWithNullCliPath) was updated to use a default CopilotClientOptions() without calling setCliPath — which is the correct migration pattern: simply don't call the setter to get the default (null) field value.

edburns and others added 7 commits March 31, 2026 13:42
…nc calls through it; shared timeout scheduler.

src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java
- Added `Executor` field, `getExecutor()`, fluent `setExecutor(Executor)` with pending-null guard, and clone support.

src/main/java/com/github/copilot/sdk/CopilotClient.java
- Extracted `startCoreBody()` from `startCore()` lambda; `supplyAsync` uses provided executor when non-null.
- `stop()` routes session-close `runAsync` through provided executor when non-null.
- Passes executor to `RpcHandlerDispatcher` constructor.
- Sets executor on new sessions via `session.setExecutor()` in `createSession` and `resumeSession`.

src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java
- Added `Executor` field and 3-arg constructor.
- All 5 `CompletableFuture.runAsync()` calls now go through private `runAsync(Runnable)` helper that uses executor when non-null.

src/main/java/com/github/copilot/sdk/CopilotSession.java
- Added `Executor` field and package-private `setExecutor()`.
- Replaced per-call `ScheduledExecutorService` with shared `timeoutScheduler` (daemon thread, shut down in `close()`).
- `executeToolAndRespondAsync` and `executePermissionAndRespondAsync` use executor when non-null.

src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java (new)
- 6 E2E tests using `TrackingExecutor` decorator to verify all `*Async` paths route through the provided executor: client start, tool call, permission, user input, hooks, and client stop.

src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java
- Updated constructor call to pass `null` for new executor parameter.
modified:   src/main/java/com/github/copilot/sdk/CopilotClient.java

- Spotless.

modified:   src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java

- Remove stub from TDD red phase.

modified:   src/site/markdown/cookbook/multiple-sessions.md

- Document new feature.

modified:   src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java

- Update test documentation.

Signed-off-by: Ed Burns <edburns@microsoft.com>
modified:   README.md

- Use the "uncomment these three lines to get Virtual Threads" approach

modified:   src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java

- Cleanup. Sorting.

Signed-off-by: Ed Burns <edburns@microsoft.com>
@edburns edburns force-pushed the edburns/dd-2758695-virtual-threads-accept-executor branch from 9098d5a to 1721ffc Compare March 31, 2026 17:51
edburns added 2 commits March 31, 2026 14:10
modified:   src/main/java/com/github/copilot/sdk/CopilotSession.java
modified:   src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java

- Spotless apply.

- Sync javadoc to behavior.
modified:   src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java

- Correctly implement the semantic of "null argument to setExecutor means use the default executor."

modified:   src/test/java/com/github/copilot/sdk/ConfigCloneTest.java

- Adjust test based on defensive copy changes.
@edburns edburns merged commit 95173fd into main Mar 31, 2026
7 checks passed
@edburns edburns deleted the edburns/dd-2758695-virtual-threads-accept-executor branch March 31, 2026 20:29
@edburns
Copy link
Copy Markdown
Collaborator Author

edburns commented Apr 2, 2026

Proof of sufficiency for Virtual Threads implementation.

17:11 Prompt

Looking overall at the potential improvements by taking JDK 21 as a minimum JDK level, and using virtual threads if they are present, is the API change exemplified in

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    var options = new CopilotClientOptions().setExecutor(executor);
    try (CopilotClient client = new CopilotClient(options)) {
        client.start().get();
        // SDK internals run on virtual threads (no carrier pinning)
        // Caller .get() calls on virtual threads also don't pin
        var session = client.createSession(config).get();
        session.sendAndWait(new MessageOptions().setPrompt("What is 2+2?")).get();
    }
}

sufficient for allowing users to get the most of virtual threads when using the public API of this codebase?

What other changes to the public API might be necessary in light of the ability to use virtual threads?

18:33 Response

Let me examine the public API surface to give you a thorough answer.

Now I have a thorough picture. Here's my analysis:


Yes, setExecutor() is the right and sufficient public API change. The virtual-thread example you showed is exactly the intended usage. But there are three internal areas that would benefit from further work if you raised the minimum to JDK 21 or detect virtual threads at runtime:

1. synchronizedReentrantLock (carrier-thread pinning)

Virtual threads pin their carrier when inside a synchronized block. The SDK has these synchronized sites:

Location Risk
JsonRpcClient.sendMessage() Medium — every RPC write acquires this monitor. Under high concurrency, virtual threads waiting to send will pin carriers.
CopilotClient.start() Low — only fires once (double-checked locking).
CopilotClient.listModels() Low — cache lock, very short hold time.
CopilotSession.close() Low — one-shot termination guard.
CopilotSession.handleSystemMessageTransform() Lowsynchronized(result) on a local map.

The sendMessage() one matters. Converting it from synchronized to ReentrantLock would allow virtual threads to unmount while waiting for the lock. No public API change needed — this is purely internal. JDK 21's synchronized pinning fix is scheduled for a later release (JEP 491 landed in JDK 24), so for JDK 21 this is a real concern.

2. Event handlers (on()) run on the reader thread

dispatchEvent() calls Consumer<AbstractSessionEvent> handlers synchronously on the jsonrpc-reader daemon thread. If a handler blocks (e.g., does I/O), it stalls all event processing. This isn't a virtual-thread issue per se, but if the SDK executor is virtual-thread-based, you might expect event dispatch to also go through it.

Possible API addition (not strictly necessary):

// Today: events dispatched synchronously on reader thread
session.on(AssistantMessageEvent.class, msg -> { ... });

// Possible: events dispatched through session executor
session.onAsync(AssistantMessageEvent.class, msg -> { ... });

However, this would change ordering semantics (events could arrive out-of-order if dispatched async). Probably leave this alone — the current design is consistent with the upstream .NET SDK.

3. The two internal daemon threads don't go through the executor

  • jsonrpc-reader (in JsonRpcClient) — platform thread, reads stdio
  • sendAndWait-timeout (in CopilotSession) — ScheduledThreadPoolExecutor

Neither uses the user-provided executor. This is actually correct by design:

  • 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; Executor doesn't support scheduled tasks, and virtual threads don't improve timer-wheel behaviour.

No API change needed here.

Summary

Area API change needed? Internal change?
setExecutor(newVirtualThreadPerTaskExecutor()) Already done ✅ Already done ✅
synchronizedReentrantLock in sendMessage() No Yes — avoids carrier pinning on JDK 21-23
Event dispatch threading No (leave as-is) No
Reader / timeout threads No No

The public API is sufficient. The one thing worth doing internally — if you target JDK 21 or want to be virtual-thread-friendly on JDK 21-23 — is replacing synchronized in JsonRpcClient.sendMessage() with a ReentrantLock. That's a one-line internal change with no public API impact.

Want to tackle the sendMessage() lock conversion?

18:37 Prompt

Maybe. But first help me understand. If someone takes our JDK 17 compiled library and runs it on JDK 25, which has JEP 491, is the synchronized thing already addressed by virtue of JEP 491?

18:40 Response

Yes. JEP 491 landed in JDK 24 (March 2025) and is a JVM runtime change, not a bytecode change. The monitorenter/monitorexit instructions emitted by javac --release 17 are identical — it's the JVM that now knows how to unmount a virtual thread while it waits to acquire a monitor, instead of pinning it to the carrier.

So: your JDK 17-compiled JAR running on JDK 24+ (including 25) automatically gets unpinned synchronized with zero code changes.

The ReentrantLock conversion would only matter for users running on JDK 21, 22, or 23 with virtual threads. Given that JDK 21 is the only LTS in that range and JDK 25 (the next LTS) includes JEP 491, the window where ReentrantLock helps is narrow and shrinking.

Probably not worth the churn.

18:41 Prompt

Next question, so let's say the user takes our JDK 17 compiled jar and runs it on JDK 25. Do they automatically get the Virtual Thread goodness, or do we need to make API changes and release a special Java 25 version of the jar to let that goodness flow through to users?

18:43 Response

They don't get it automatically. Two reasons:

  1. ForkJoinPool.commonPool() is still platform threads on JDK 25. The JVM doesn't retroactively switch the common pool to virtual threads. CompletableFuture.runAsync(task) without an explicit executor still runs on platform threads.

  2. Virtual threads are opt-in. You only get them by explicitly calling Executors.newVirtualThreadPerTaskExecutor() or Thread.ofVirtual().

So on JDK 25, users still need this one line to unlock virtual threads:

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

No special JDK 25 build of the jar is required — our JDK 17-compiled jar accepts any Executor, and newVirtualThreadPerTaskExecutor() returns a standard ExecutorService. It just works.

The alternative would be to auto-detect virtual thread support at runtime and use it as the default:

// Hypothetical: in CopilotClient, when executor is null
private static Executor defaultExecutor() {
    try {
        return (ExecutorService) Executors.class
            .getMethod("newVirtualThreadPerTaskExecutor")
            .invoke(null);
    } catch (ReflectiveOperationException e) {
        return ForkJoinPool.commonPool(); // JDK 17-20 fallback
    }
}

But that changes default behavior invisibly, which could surprise users (virtual threads have different ThreadLocal and stack-trace characteristics). The explicit setExecutor() opt-in is the safer design — one line of user code, no special jar.

@edburns
Copy link
Copy Markdown
Collaborator Author

edburns commented Apr 2, 2026

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants