Skip to content

Commit a5be150

Browse files
committed
Harden session detach cleanup
Serialize and finalize disconnect cleanup consistently across SDKs, unregister manually detached Go and Java sessions, propagate Java detach failures, and teach fake runtimes the detach response contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 69723dd2-e732-43c6-9f9a-a16c2de3a628
1 parent a079c26 commit a5be150

16 files changed

Lines changed: 199 additions & 52 deletions

File tree

dotnet/src/Session.cs

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,18 +1939,17 @@ public async ValueTask DisposeAsync()
19391939
finally
19401940
{
19411941
RemoveFromClient();
1942+
_eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray<EventSubscription>.Empty);
1943+
_toolHandlers.Clear();
1944+
_commandHandlers.Clear();
1945+
1946+
_permissionHandler = null;
1947+
_userInputHandler = null;
1948+
_elicitationHandler = null;
1949+
_exitPlanModeHandler = null;
1950+
_autoModeSwitchHandler = null;
19421951
GC.SuppressFinalize(this);
19431952
}
1944-
1945-
_eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray<EventSubscription>.Empty);
1946-
_toolHandlers.Clear();
1947-
_commandHandlers.Clear();
1948-
1949-
_permissionHandler = null;
1950-
_userInputHandler = null;
1951-
_elicitationHandler = null;
1952-
_exitPlanModeHandler = null;
1953-
_autoModeSwitchHandler = null;
19541953
}
19551954

19561955
[LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception in broadcast event handler")]

dotnet/test/E2E/ClientOptionsE2ETests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,10 @@ function handleMessage(message) {
10681068
writeResponse(message.id, { messageId: "fake-message" });
10691069
return;
10701070
}
1071+
if (message.method === "session.detach") {
1072+
writeResponse(message.id, { success: true });
1073+
return;
1074+
}
10711075
10721076
writeResponse(message.id, {});
10731077
}

go/client.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,13 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
924924
"",
925925
hasManagedSettings(config.EnableManagedSettings, config.ManagedSettings),
926926
)
927+
s.onDisconnected = func() {
928+
c.sessionsMux.Lock()
929+
defer c.sessionsMux.Unlock()
930+
if c.sessions[sessionID] == s {
931+
delete(c.sessions, sessionID)
932+
}
933+
}
927934

928935
s.registerTools(config.Tools)
929936
s.registerPermissionHandler(config.OnPermissionRequest)
@@ -1258,6 +1265,13 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
12581265
"",
12591266
hasManagedSettings(config.EnableManagedSettings, config.ManagedSettings),
12601267
)
1268+
session.onDisconnected = func() {
1269+
c.sessionsMux.Lock()
1270+
defer c.sessionsMux.Unlock()
1271+
if c.sessions[sessionID] == session {
1272+
delete(c.sessions, sessionID)
1273+
}
1274+
}
12611275

12621276
session.registerTools(config.Tools)
12631277
session.registerPermissionHandler(config.OnPermissionRequest)

go/client_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,6 +1939,36 @@ func TestClient_MCPAuthInterestRegistration(t *testing.T) {
19391939
})
19401940
}
19411941

1942+
func TestSessionDisconnectUnregistersBeforeClientStop(t *testing.T) {
1943+
client, requests, cleanup := newInMemoryClient(t)
1944+
defer cleanup()
1945+
1946+
session, err := client.CreateSession(t.Context(), &SessionConfig{
1947+
OnPermissionRequest: PermissionHandler.ApproveAll,
1948+
})
1949+
if err != nil {
1950+
t.Fatalf("CreateSession failed: %v", err)
1951+
}
1952+
requests.clear()
1953+
1954+
if err := session.Disconnect(); err != nil {
1955+
t.Fatalf("Disconnect failed: %v", err)
1956+
}
1957+
if err := client.Stop(); err != nil {
1958+
t.Fatalf("Stop failed: %v", err)
1959+
}
1960+
1961+
detachCount := 0
1962+
for _, request := range requests.snapshot() {
1963+
if request.Method == "session.detach" {
1964+
detachCount++
1965+
}
1966+
}
1967+
if detachCount != 1 {
1968+
t.Fatalf("expected exactly one session.detach request, got %d", detachCount)
1969+
}
1970+
}
1971+
19421972
type recordedRequest struct {
19431973
Method string
19441974
Params map[string]any

go/session.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,11 @@ type Session struct {
9595

9696
// eventCh serializes user event handler dispatch. dispatchEvent enqueues;
9797
// a single goroutine (processEvents) dequeues and invokes handlers in FIFO order.
98-
eventCh chan SessionEvent
99-
closeOnce sync.Once // guards eventCh close so Disconnect is safe to call more than once
98+
eventCh chan SessionEvent
99+
closeOnce sync.Once // guards eventCh close so Disconnect is safe to call more than once
100+
disconnectMu sync.Mutex
101+
disconnected bool
102+
onDisconnected func()
100103

101104
// RPC provides typed session-scoped RPC methods.
102105
RPC *rpc.SessionRPC
@@ -1716,6 +1719,12 @@ func (s *Session) GetEvents(ctx context.Context) ([]SessionEvent, error) {
17161719
// log.Printf("Failed to disconnect session: %v", err)
17171720
// }
17181721
func (s *Session) Disconnect() error {
1722+
s.disconnectMu.Lock()
1723+
defer s.disconnectMu.Unlock()
1724+
if s.disconnected {
1725+
return nil
1726+
}
1727+
17191728
result, err := s.client.Request(context.Background(), "session.detach", sessionDetachRequest{SessionID: s.SessionID})
17201729
if err != nil {
17211730
return fmt.Errorf("failed to disconnect session: %w", err)
@@ -1731,6 +1740,7 @@ func (s *Session) Disconnect() error {
17311740
return fmt.Errorf("failed to disconnect session: %s", response.Error)
17321741
}
17331742

1743+
s.disconnected = true
17341744
s.closeOnce.Do(func() { close(s.eventCh) })
17351745

17361746
// Clear handlers
@@ -1754,6 +1764,9 @@ func (s *Session) Disconnect() error {
17541764
s.elicitationHandler = nil
17551765
s.elicitationMu.Unlock()
17561766

1767+
if s.onDisconnected != nil {
1768+
s.onDisconnected()
1769+
}
17571770
return nil
17581771
}
17591772

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

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -381,26 +381,32 @@ public CompletableFuture<Void> stop() {
381381

382382
for (CopilotSession session : new ArrayList<>(sessions.values())) {
383383
Runnable closeTask = () -> {
384-
try {
385-
session.close();
386-
} catch (Exception e) {
387-
LOG.log(Level.WARNING, "Error closing session " + session.getSessionId(), e);
388-
}
384+
session.close();
389385
};
390386
CompletableFuture<Void> future;
391387
try {
392388
future = CompletableFuture.runAsync(closeTask, executor);
393389
} catch (RejectedExecutionException e) {
394390
LOG.log(Level.WARNING, "Executor rejected session close task; closing inline", e);
395-
closeTask.run();
396-
future = CompletableFuture.completedFuture(null);
391+
try {
392+
closeTask.run();
393+
future = CompletableFuture.completedFuture(null);
394+
} catch (RuntimeException closeError) {
395+
future = CompletableFuture.failedFuture(closeError);
396+
}
397397
}
398398
closeFutures.add(future);
399399
}
400400
sessions.clear();
401401

402402
return CompletableFuture.allOf(closeFutures.toArray(new CompletableFuture[0]))
403-
.thenCompose(v -> cleanupConnection(true));
403+
.handle((ignored, closeError) -> closeError)
404+
.thenCompose(closeError -> cleanupConnection(true).thenApply(ignored -> {
405+
if (closeError != null) {
406+
throw new CompletionException(closeError);
407+
}
408+
return null;
409+
}));
404410
}
405411

406412
/**
@@ -571,6 +577,7 @@ public CompletableFuture<CopilotSession> createSession(SessionConfig config) {
571577
long setupNanos = System.nanoTime();
572578
var s = new CopilotSession(sid, connection.rpc);
573579
s.setExecutor(executor);
580+
s.setOnClosed(() -> sessions.remove(sid, s));
574581
SessionRequestBuilder.configureSession(s, config);
575582
if (extracted.transformCallbacks() != null) {
576583
s.registerTransformCallbacks(extracted.transformCallbacks());
@@ -743,6 +750,7 @@ public CompletableFuture<CopilotSession> resumeSession(String sessionId, ResumeS
743750
long setupNanos = System.nanoTime();
744751
var session = new CopilotSession(sessionId, connection.rpc);
745752
session.setExecutor(executor);
753+
session.setOnClosed(() -> sessions.remove(sessionId, session));
746754
SessionRequestBuilder.configureSession(session, config);
747755
sessions.put(sessionId, session);
748756
LoggingHelpers.logTiming(LOG, Level.FINE,

java/src/main/java/com/github/copilot/CopilotSession.java

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ public final class CopilotSession implements AutoCloseable {
201201

202202
/** Tracks whether this session instance has been terminated via close(). */
203203
private volatile boolean isTerminated = false;
204+
private volatile Runnable onClosed = () -> {
205+
};
204206

205207
/**
206208
* Creates a new session with the given ID and RPC client.
@@ -252,6 +254,10 @@ void setExecutor(Executor executor) {
252254
this.executor = executor;
253255
}
254256

257+
void setOnClosed(Runnable onClosed) {
258+
this.onClosed = onClosed;
259+
}
260+
255261
/**
256262
* Gets the unique identifier for this session.
257263
*
@@ -2302,19 +2308,20 @@ public void close() {
23022308

23032309
timeoutScheduler.shutdownNow();
23042310

2311+
RuntimeException detachFailure = null;
23052312
try {
23062313
SessionDetachResponse response = rpc
23072314
.invoke("session.detach", Map.of("sessionId", sessionId), SessionDetachResponse.class)
23082315
.get(5, TimeUnit.SECONDS);
23092316
if (response == null || !response.success()) {
2310-
LOG.log(Level.FINE, "Failed to detach session {0}: {1}",
2311-
new Object[] {
2312-
sessionId,
2313-
response != null && response.error() != null ? response.error() : "unknown error"
2314-
});
2317+
String detail = response != null && response.error() != null ? response.error() : "unknown error";
2318+
detachFailure = new IllegalStateException("Failed to detach session " + sessionId + ": " + detail);
23152319
}
23162320
} catch (Exception e) {
2317-
LOG.log(Level.FINE, "Error detaching session", e);
2321+
if (e instanceof InterruptedException) {
2322+
Thread.currentThread().interrupt();
2323+
}
2324+
detachFailure = new IllegalStateException("Failed to detach session " + sessionId, e);
23182325
}
23192326

23202327
eventHandlers.clear();
@@ -2326,13 +2333,17 @@ public void close() {
23262333
exitPlanModeHandler.set(null);
23272334
autoModeSwitchHandler.set(null);
23282335
hooksHandler.set(null);
2336+
onClosed.run();
2337+
2338+
if (detachFailure != null) {
2339+
throw detachFailure;
2340+
}
23292341
}
23302342

23312343
// ===== Internal response types for agent API =====
23322344

23332345
@JsonIgnoreProperties(ignoreUnknown = true)
2334-
private record SessionDetachResponse(@JsonProperty("success") boolean success,
2335-
@JsonProperty("error") String error) {
2346+
record SessionDetachResponse(@JsonProperty("success") boolean success, @JsonProperty("error") String error) {
23362347
}
23372348

23382349
@JsonIgnoreProperties(ignoreUnknown = true)

java/src/test/java/com/github/copilot/ClientOptionsE2ETest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ function resultFor(message) {
265265
return { sessionId: message.params?.sessionId ?? 'fake-session', openCanvases: [] };
266266
case 'session.options.update':
267267
return { success: true };
268+
case 'session.detach':
269+
return { success: true };
268270
default:
269271
return {};
270272
}

java/src/test/java/com/github/copilot/TimeoutEdgeCaseTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package com.github.copilot;
66

77
import static org.junit.jupiter.api.Assertions.assertFalse;
8+
import static org.junit.jupiter.api.Assertions.assertThrows;
89
import static org.junit.jupiter.api.Assertions.assertTrue;
910

1011
import java.io.ByteArrayOutputStream;
@@ -81,7 +82,7 @@ void testTimeoutDoesNotFireAfterSessionClose() throws Exception {
8182

8283
// close() blocks up to 5s on session.detach RPC. The 2s timeout
8384
// fires during that window with the current per-call scheduler.
84-
session.close();
85+
assertThrows(IllegalStateException.class, session::close);
8586

8687
assertFalse(result.isDone(), "Future should not be completed by a timeout after session is closed. "
8788
+ "The per-call ScheduledExecutorService leaked a TimeoutException.");
@@ -126,6 +127,7 @@ void testSendAndWaitReusesTimeoutThread() throws Exception {
126127

127128
result1.cancel(true);
128129
result2.cancel(true);
130+
assertThrows(IllegalStateException.class, session::close);
129131
}
130132
} finally {
131133
rpc.close();

java/src/test/java/com/github/copilot/ZeroTimeoutContractTest.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@
2121
*/
2222
public class ZeroTimeoutContractTest {
2323

24+
@SuppressWarnings("unchecked")
25+
@Test
26+
void closeShouldPropagateDetachFailureAndRemainTerminal() {
27+
var mockRpc = mock(JsonRpcClient.class);
28+
when(mockRpc.invoke(eq("session.detach"), any(), any())).thenReturn(
29+
CompletableFuture.completedFuture(new CopilotSession.SessionDetachResponse(false, "cleanup failed")));
30+
var session = new CopilotSession("detach-failure-test", mockRpc);
31+
32+
var error = assertThrows(IllegalStateException.class, session::close);
33+
34+
assertTrue(error.getMessage().contains("cleanup failed"));
35+
assertThrows(IllegalStateException.class, () -> session.send("test"));
36+
assertDoesNotThrow(session::close);
37+
}
38+
2439
@SuppressWarnings("unchecked")
2540
@Test
2641
void sendAndWaitWithZeroTimeoutShouldNotTimeOut() throws Exception {
@@ -33,7 +48,7 @@ void sendAndWaitWithZeroTimeoutShouldNotTimeOut() throws Exception {
3348
Object method = invocation.getArgument(0);
3449
if ("session.detach".equals(method)) {
3550
// Make session.close() non-blocking by completing detach immediately
36-
return CompletableFuture.completedFuture(null);
51+
return CompletableFuture.completedFuture(new CopilotSession.SessionDetachResponse(true, null));
3752
}
3853
// For other calls (e.g., message send), return an incomplete future so the
3954
// sendAndWait result does not complete due to a mock response.

0 commit comments

Comments
 (0)