Skip to content

Commit 25f1069

Browse files
committed
On branch edburns/dd-2758695-virtual-threads-accept-executor
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>
1 parent 231dc24 commit 25f1069

File tree

4 files changed

+87
-36
lines changed

4 files changed

+87
-36
lines changed

src/main/java/com/github/copilot/sdk/CopilotClient.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,7 @@ private Connection startCoreBody() {
188188
} catch (Exception e) {
189189
String stderr = serverManager.getStderrOutput();
190190
if (!stderr.isEmpty()) {
191-
throw new CompletionException(
192-
new IOException("CLI process exited unexpectedly. stderr: " + stderr, e));
191+
throw new CompletionException(new IOException("CLI process exited unexpectedly. stderr: " + stderr, e));
193192
}
194193
throw new CompletionException(e);
195194
}
@@ -245,9 +244,8 @@ public CompletableFuture<Void> stop() {
245244
LOG.log(Level.WARNING, "Error closing session " + session.getSessionId(), e);
246245
}
247246
};
248-
closeFutures.add(exec != null
249-
? CompletableFuture.runAsync(closeTask, exec)
250-
: CompletableFuture.runAsync(closeTask));
247+
closeFutures.add(
248+
exec != null ? CompletableFuture.runAsync(closeTask, exec) : CompletableFuture.runAsync(closeTask));
251249
}
252250
sessions.clear();
253251

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -429,18 +429,14 @@ public Executor getExecutor() {
429429
* <p>
430430
* When provided, the SDK uses this executor for all internal
431431
* {@code CompletableFuture} combinators instead of the default
432-
* {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK
433-
* work onto a dedicated thread pool or integrate with container-managed
434-
* threading.
432+
* {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK work
433+
* onto a dedicated thread pool or integrate with container-managed threading.
435434
*
436435
* @param executor
437436
* the executor to use, or {@code null} for the default
438437
* @return this options instance for fluent chaining
439438
*/
440439
public CopilotClientOptions setExecutor(Executor executor) {
441-
if (null == executor) {
442-
throw new IllegalArgumentException("PENDING(copilot): not implemented");
443-
}
444440
this.executor = executor;
445441
return this;
446442
}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,69 @@ public class ParallelSessions {
164164
}
165165
```
166166

167+
## Providing a custom Executor for parallel sessions
168+
169+
By default, `CompletableFuture` operations run on `ForkJoinPool.commonPool()`,
170+
which has limited parallelism (typically `Runtime.availableProcessors() - 1`
171+
threads). When multiple sessions block waiting for CLI responses, those threads
172+
are unavailable for other work—a condition known as *pool starvation*.
173+
174+
Use `CopilotClientOptions.setExecutor(Executor)` to supply a dedicated thread
175+
pool so that SDK work does not compete with the rest of your application for
176+
common-pool threads:
177+
178+
```java
179+
//DEPS com.github:copilot-sdk-java:${project.version}
180+
import com.github.copilot.sdk.CopilotClient;
181+
import com.github.copilot.sdk.json.CopilotClientOptions;
182+
import com.github.copilot.sdk.json.SessionConfig;
183+
import com.github.copilot.sdk.json.MessageOptions;
184+
import com.github.copilot.sdk.json.PermissionHandler;
185+
import java.util.List;
186+
import java.util.concurrent.CompletableFuture;
187+
import java.util.concurrent.ExecutorService;
188+
import java.util.concurrent.Executors;
189+
190+
public class ParallelSessionsWithExecutor {
191+
public static void main(String[] args) throws Exception {
192+
ExecutorService pool = Executors.newFixedThreadPool(4);
193+
try {
194+
var options = new CopilotClientOptions().setExecutor(pool);
195+
try (CopilotClient client = new CopilotClient(options)) {
196+
client.start().get();
197+
198+
var s1 = client.createSession(new SessionConfig()
199+
.setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
200+
.setModel("gpt-5")).get();
201+
var s2 = client.createSession(new SessionConfig()
202+
.setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
203+
.setModel("gpt-5")).get();
204+
var s3 = client.createSession(new SessionConfig()
205+
.setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
206+
.setModel("claude-sonnet-4.5")).get();
207+
208+
CompletableFuture.allOf(
209+
s1.sendAndWait(new MessageOptions().setPrompt("Question 1")),
210+
s2.sendAndWait(new MessageOptions().setPrompt("Question 2")),
211+
s3.sendAndWait(new MessageOptions().setPrompt("Question 3"))
212+
).get();
213+
214+
s1.close();
215+
s2.close();
216+
s3.close();
217+
}
218+
} finally {
219+
pool.shutdown();
220+
}
221+
}
222+
}
223+
```
224+
225+
Passing `null` (or omitting `setExecutor` entirely) keeps the default
226+
`ForkJoinPool.commonPool()` behaviour. The executor is used for all internal
227+
`CompletableFuture.runAsync` / `supplyAsync` calls—including client start/stop,
228+
tool-call dispatch, permission dispatch, user-input dispatch, and hooks.
229+
167230
## Use cases
168231

169232
- **Multi-user applications**: One session per user

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

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import com.github.copilot.sdk.json.CopilotClientOptions;
2626
import com.github.copilot.sdk.json.MessageOptions;
2727
import com.github.copilot.sdk.json.PermissionHandler;
28-
import com.github.copilot.sdk.json.PermissionRequest;
2928
import com.github.copilot.sdk.json.PermissionRequestResult;
3029
import com.github.copilot.sdk.json.PreToolUseHookOutput;
3130
import com.github.copilot.sdk.json.SessionConfig;
@@ -62,8 +61,8 @@ static void teardown() throws Exception {
6261
}
6362

6463
/**
65-
* A decorator executor that delegates to a real executor while counting
66-
* task submissions.
64+
* A decorator executor that delegates to a real executor while counting task
65+
* submissions.
6766
*/
6867
static class TrackingExecutor implements Executor {
6968

@@ -101,8 +100,8 @@ private CopilotClientOptions createOptionsWithExecutor(TrackingExecutor executor
101100
*
102101
* <p>
103102
* {@code CopilotClient.startCore()} uses
104-
* {@code CompletableFuture.supplyAsync(...)} to initialize the connection.
105-
* This test asserts that the start-up task goes through the caller-supplied
103+
* {@code CompletableFuture.supplyAsync(...)} to initialize the connection. This
104+
* test asserts that the start-up task goes through the caller-supplied
106105
* executor, not {@code ForkJoinPool.commonPool()}.
107106
* </p>
108107
*
@@ -129,9 +128,8 @@ void testClientStartUsesProvidedExecutor() throws Exception {
129128
* Verifies that tool call dispatch routes through the provided executor.
130129
*
131130
* <p>
132-
* When a custom tool is invoked by the LLM, the
133-
* {@code RpcHandlerDispatcher} calls
134-
* {@code CompletableFuture.runAsync(...)} to dispatch the tool handler.
131+
* When a custom tool is invoked by the LLM, the {@code RpcHandlerDispatcher}
132+
* calls {@code CompletableFuture.runAsync(...)} to dispatch the tool handler.
135133
* This test asserts that dispatch goes through the caller-supplied executor.
136134
* </p>
137135
*
@@ -187,10 +185,9 @@ void testToolCallDispatchUsesProvidedExecutor() throws Exception {
187185
* executor.
188186
*
189187
* <p>
190-
* When the LLM requests a permission, the {@code RpcHandlerDispatcher}
191-
* calls {@code CompletableFuture.runAsync(...)} to dispatch the permission
192-
* handler. This test asserts that dispatch goes through the caller-supplied
193-
* executor.
188+
* When the LLM requests a permission, the {@code RpcHandlerDispatcher} calls
189+
* {@code CompletableFuture.runAsync(...)} to dispatch the permission handler.
190+
* This test asserts that dispatch goes through the caller-supplied executor.
194191
* </p>
195192
*
196193
* @see Snapshot: permissions/permission_handler_for_write_operations
@@ -212,8 +209,7 @@ void testPermissionDispatchUsesProvidedExecutor() throws Exception {
212209

213210
int beforeSend = trackingExecutor.getTaskCount();
214211

215-
session.sendAndWait(
216-
new MessageOptions().setPrompt("Edit test.txt and replace 'original' with 'modified'"))
212+
session.sendAndWait(new MessageOptions().setPrompt("Edit test.txt and replace 'original' with 'modified'"))
217213
.get(60, TimeUnit.SECONDS);
218214

219215
assertTrue(trackingExecutor.getTaskCount() > beforeSend,
@@ -231,9 +227,8 @@ void testPermissionDispatchUsesProvidedExecutor() throws Exception {
231227
*
232228
* <p>
233229
* When the LLM asks for user input, the {@code RpcHandlerDispatcher} calls
234-
* {@code CompletableFuture.runAsync(...)} to dispatch the user input
235-
* handler. This test asserts that dispatch goes through the caller-supplied
236-
* executor.
230+
* {@code CompletableFuture.runAsync(...)} to dispatch the user input handler.
231+
* This test asserts that dispatch goes through the caller-supplied executor.
237232
* </p>
238233
*
239234
* @see Snapshot:
@@ -278,8 +273,8 @@ void testUserInputDispatchUsesProvidedExecutor() throws Exception {
278273
*
279274
* <p>
280275
* When the LLM triggers a hook, the {@code RpcHandlerDispatcher} calls
281-
* {@code CompletableFuture.runAsync(...)} to dispatch the hooks handler.
282-
* This test asserts that dispatch goes through the caller-supplied executor.
276+
* {@code CompletableFuture.runAsync(...)} to dispatch the hooks handler. This
277+
* test asserts that dispatch goes through the caller-supplied executor.
283278
* </p>
284279
*
285280
* @see Snapshot: hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool
@@ -316,14 +311,13 @@ void testHooksDispatchUsesProvidedExecutor() throws Exception {
316311
}
317312

318313
/**
319-
* Verifies that {@code CopilotClient.stop()} routes session closure through
320-
* the provided executor.
314+
* Verifies that {@code CopilotClient.stop()} routes session closure through the
315+
* provided executor.
321316
*
322317
* <p>
323-
* {@code CopilotClient.stop()} uses
324-
* {@code CompletableFuture.runAsync(...)} to close each active session.
325-
* This test asserts that those closures go through the caller-supplied
326-
* executor.
318+
* {@code CopilotClient.stop()} uses {@code CompletableFuture.runAsync(...)} to
319+
* close each active session. This test asserts that those closures go through
320+
* the caller-supplied executor.
327321
* </p>
328322
*
329323
* @see Snapshot: tools/invokes_custom_tool

0 commit comments

Comments
 (0)