Skip to content

Commit a079c26

Browse files
committed
Migrate SDK session cleanup to detach
Use the ownership-aware session.detach RPC for session disposal, client shutdown, and initialization rollback across every SDK. Validate unsuccessful detach responses and update lifecycle tests and fake runtimes for the released wire contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 69723dd2-e732-43c6-9f9a-a16c2de3a628
1 parent 3bd902e commit a079c26

25 files changed

Lines changed: 182 additions & 151 deletions

dotnet/src/Session.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,8 +1920,13 @@ public async ValueTask DisposeAsync()
19201920

19211921
try
19221922
{
1923-
await InvokeRpcAsync<object>(
1924-
"session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None);
1923+
var response = await InvokeRpcAsync<SessionDetachResponse>(
1924+
"session.detach", [new SessionDetachRequest() { SessionId = SessionId }], CancellationToken.None);
1925+
if (!response.Success)
1926+
{
1927+
throw new InvalidOperationException(
1928+
$"Failed to detach session {SessionId}: {response.Error ?? "unknown error"}");
1929+
}
19251930
}
19261931
catch (ObjectDisposedException)
19271932
{
@@ -1991,11 +1996,17 @@ internal record SessionAbortRequest
19911996
public string SessionId { get; init; } = string.Empty;
19921997
}
19931998

1994-
internal record SessionDestroyRequest
1999+
internal record SessionDetachRequest
19952000
{
19962001
public string SessionId { get; init; } = string.Empty;
19972002
}
19982003

2004+
internal record SessionDetachResponse
2005+
{
2006+
public bool Success { get; init; }
2007+
public string? Error { get; init; }
2008+
}
2009+
19992010
internal void ThrowIfDisposed()
20002011
{
20012012
ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this);
@@ -2028,7 +2039,8 @@ internal void ThrowIfDisposed()
20282039
[JsonSerializable(typeof(SendMessageRequest))]
20292040
[JsonSerializable(typeof(SendMessageResponse))]
20302041
[JsonSerializable(typeof(SessionAbortRequest))]
2031-
[JsonSerializable(typeof(SessionDestroyRequest))]
2042+
[JsonSerializable(typeof(SessionDetachRequest))]
2043+
[JsonSerializable(typeof(SessionDetachResponse))]
20322044
[JsonSerializable(typeof(SessionEndHookInput))]
20332045
[JsonSerializable(typeof(SessionEndHookOutput))]
20342046
[JsonSerializable(typeof(SessionStartHookInput))]

dotnet/test/E2E/ClientLifecycleE2ETests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ public async Task Should_Receive_Session_Deleted_Lifecycle_Event_When_Deleted()
121121
}
122122
});
123123

124-
// Do NOT DisposeAsync the session before deleting: dispose sends session.destroy
124+
// Do NOT DisposeAsync the session before deleting: dispose sends session.detach
125125
// which closes in-memory state but does not remove the disk file; calling
126126
// delete afterwards still succeeds, but skipping dispose keeps the test minimal.
127127
await Client.DeleteSessionAsync(sessionId);

dotnet/test/Harness/E2ETestBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ protected static async Task SuspendAndUntrackSessionForResumeAsync(CopilotSessio
113113
{
114114
await session.Rpc.SuspendAsync();
115115

116-
// In-process clients host separate runtimes, while session.destroy removes the
116+
// In-process clients host separate runtimes, while session.detach removes the
117117
// session from the current runtime. Untrack locally to exercise resume without
118118
// either replacing an active wrapper or destroying the session first.
119119
var removeFromClient = typeof(CopilotSession).GetMethod(

dotnet/test/Harness/E2ETestContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ private static async Task StopClientForCleanupAsync(CopilotClient client)
569569
$"Graceful in-process client cleanup exceeded {s_gracefulClientStopTimeout}; forcing shutdown.");
570570
await client.ForceStopAsync();
571571

572-
// Disposing the connection completes any session.destroy RPC that
572+
// Disposing the connection completes any session.detach RPC that
573573
// blocked graceful cleanup. Observe that task before continuing.
574574
await gracefulStop.WaitAsync(s_gracefulClientStopTimeout);
575575
}

dotnet/test/Unit/ClientSessionLifetimeTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -847,7 +847,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
847847
{
848848
["success"] = true
849849
},
850-
"session.destroy" => await DestroySessionAsync(cancellationToken),
850+
"session.detach" => await DetachSessionAsync(cancellationToken),
851851
"runtime.shutdown" => HandleRuntimeShutdown(),
852852
_ => throw new InvalidOperationException($"Unexpected RPC method '{method}'.")
853853
};
@@ -884,15 +884,15 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
884884
};
885885
}
886886

887-
private async Task<Dictionary<string, object?>> DestroySessionAsync(CancellationToken cancellationToken)
887+
private async Task<Dictionary<string, object?>> DetachSessionAsync(CancellationToken cancellationToken)
888888
{
889889
if (_delayDestroy)
890890
{
891891
_destroyStarted.TrySetResult();
892892
await _allowDestroy.Task.WaitAsync(cancellationToken);
893893
}
894894

895-
return [];
895+
return new Dictionary<string, object?> { ["success"] = true };
896896
}
897897

898898
private Dictionary<string, object?> HandleRuntimeShutdown()

dotnet/test/Unit/GitHubTelemetryTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
350350
"session.create" => CaptureCreate(request),
351351
"session.resume" => CaptureResume(request),
352352
"session.send" => new Dictionary<string, object?> { ["messageId"] = "message-1" },
353-
"session.destroy" => new Dictionary<string, object?>(),
353+
"session.detach" => new Dictionary<string, object?> { ["success"] = true },
354354
"session.options.update" => new Dictionary<string, object?> { ["success"] = true },
355355
"runtime.shutdown" => new Dictionary<string, object?>(),
356356
_ => throw new InvalidOperationException($"Unexpected RPC method '{method}'."),

go/client_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2032,8 +2032,10 @@ func serveInMemoryRuntime(t *testing.T, stdinR *io.PipeReader, stdoutW *io.PipeW
20322032
result = map[string]any{"id": "interest-1"}
20332033
case "session.options.update":
20342034
result = map[string]any{"success": true}
2035-
case "session.skills.reload", "session.destroy":
2035+
case "session.skills.reload":
20362036
result = map[string]any{}
2037+
case "session.detach":
2038+
result = map[string]any{"success": true}
20372039
default:
20382040
t.Errorf("unexpected JSON-RPC method %s", request.Method)
20392041
return

go/internal/e2e/client_options_e2e_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,10 @@ function handleMessage(message) {
874874
writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null });
875875
return;
876876
}
877+
if (message.method === "session.detach") {
878+
writeResponse(message.id, { success: true });
879+
return;
880+
}
877881
writeResponse(message.id, {});
878882
}
879883

go/session.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1716,10 +1716,20 @@ func (s *Session) GetEvents(ctx context.Context) ([]SessionEvent, error) {
17161716
// log.Printf("Failed to disconnect session: %v", err)
17171717
// }
17181718
func (s *Session) Disconnect() error {
1719-
_, err := s.client.Request(context.Background(), "session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
1719+
result, err := s.client.Request(context.Background(), "session.detach", sessionDetachRequest{SessionID: s.SessionID})
17201720
if err != nil {
17211721
return fmt.Errorf("failed to disconnect session: %w", err)
17221722
}
1723+
var response sessionDetachResponse
1724+
if err := json.Unmarshal(result, &response); err != nil {
1725+
return fmt.Errorf("failed to decode session detach response: %w", err)
1726+
}
1727+
if !response.Success {
1728+
if response.Error == "" {
1729+
response.Error = "unknown error"
1730+
}
1731+
return fmt.Errorf("failed to disconnect session: %s", response.Error)
1732+
}
17231733

17241734
s.closeOnce.Do(func() { close(s.eventCh) })
17251735

go/types.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2698,11 +2698,16 @@ type sessionGetMessagesResponse struct {
26982698
Events []SessionEvent `json:"events"`
26992699
}
27002700

2701-
// sessionDestroyRequest is the request for session.destroy
2702-
type sessionDestroyRequest struct {
2701+
// sessionDetachRequest is the request for session.detach
2702+
type sessionDetachRequest struct {
27032703
SessionID string `json:"sessionId"`
27042704
}
27052705

2706+
type sessionDetachResponse struct {
2707+
Success bool `json:"success"`
2708+
Error string `json:"error,omitempty"`
2709+
}
2710+
27062711
// sessionAbortRequest is the request for session.abort
27072712
type sessionAbortRequest struct {
27082713
SessionID string `json:"sessionId"`

0 commit comments

Comments
 (0)