Conversation
There was a problem hiding this comment.
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 inclone(). - Wires the executor through
CopilotClientstart/stop paths,RpcHandlerDispatcher, andCopilotSessiontool/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);
}
|
@edburns should we throw an IAE if the user passes null? |
|
@brunoborges wrote:
We don't do that for other setters in |
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. |
|
@brunoborges wrote:
Of course. I'll take this as consent to add the null checks throughout, at least in the I'll take the remainder of the null checks as a separate issue. |
Done in commit 9098d5a. All reference-type setters in The one test that explicitly called |
…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>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/93199b25-7c90-4c45-9540-527396b8990c Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/63b9b09f-f1f4-44d3-8e34-ad01e355cc6a Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
…sion Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/63b9b09f-f1f4-44d3-8e34-ad01e355cc6a Co-authored-by: edburns <75821+edburns@users.noreply.github.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>
Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/019cc9a8-a29a-49a5-a7ac-aa573931dfb8 Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
9098d5a to
1721ffc
Compare
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.
|
Proof of sufficiency for Virtual Threads implementation. 17:11 PromptLooking 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 ResponseLet me examine the public API surface to give you a thorough answer. Now I have a thorough picture. Here's my analysis: Yes, 1.
|
| 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() |
Low — synchronized(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(inJsonRpcClient) — platform thread, reads stdiosendAndWait-timeout(inCopilotSession) —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;Executordoesn'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 ✅ |
synchronized → ReentrantLock 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:
-
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. -
Virtual threads are opt-in. You only get them by explicitly calling
Executors.newVirtualThreadPerTaskExecutor()orThread.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.
|
Full prompt files for this work. 01-mock-llm-to-show-blocking.md |
Before the change?
CompletableFuture.*Asynccalls usedForkJoinPool.commonPool(), which could become starved when multiple sessions blocked concurrently.CopilotClientOptionshad no way for callers to supply a custom thread pool.RejectedExecutionExceptionthrown synchronously by any async submission site would propagate to callers unexpectedly, potentially leaving JSON-RPC requests unanswered or skipping connection cleanup.CopilotClientOptionssilently acceptednull, making the API error-prone.After the change?
CopilotClientOptionshas a newExecutorfield with fluentsetExecutor(Executor)/getExecutor()accessors. When not set, the defaultForkJoinPool.commonPool()behaviour is preserved.CopilotClientstart/stop,RpcHandlerDispatcher(all 5runAsynccall sites via a private helper), andCopilotSession(executeToolAndRespondAsync/executePermissionAndRespondAsync).RejectedExecutionExceptionand handle it context-sensitively:CopilotClient.startCore()— returnsCompletableFuture.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()andexecutePermissionAndRespondAsync()— fall back to running the task inline so pending tool/permission calls always receive a reply.CopilotClientOptionsnow throwNullPointerExceptionviaObjects.requireNonNullwhennullis 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.RpcHandlerDispatcherTestupdated for the new 3-arg constructor (passesnull).CliServerManagerTestupdated: the test that previously relied onsetCliPath(null)to exercise the default-to-"copilot"code path now uses a defaultCopilotClientOptions()without callingsetCliPath.Pull request checklist
mvn spotless:applyhas been run to format the codemvn clean verifypasses locallyDoes this introduce a breaking change?