Skip to content

Commit fcffcdf

Browse files
jmoseleySteveSandersonMSCopilot
authored
Use session detach for SDK cleanup (#2307)
Add real-runtime cold resume coverage across SDKs so disconnect preserves persisted sessions across client/runtime restart. Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent eb38014 commit fcffcdf

39 files changed

Lines changed: 563 additions & 104 deletions

‎dotnet/src/Session.cs‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2109,8 +2109,12 @@ public async ValueTask DisposeAsync()
21092109

21102110
try
21112111
{
2112-
await InvokeRpcAsync<object>(
2113-
"session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None);
2112+
var response = await InvokeRpcAsync<SessionDetachResponse>(
2113+
"session.detach", [new SessionDetachRequest() { SessionId = SessionId }], CancellationToken.None);
2114+
if (!response.Success)
2115+
{
2116+
LogSessionDetachFailed(SessionId, response.Error ?? "unknown error");
2117+
}
21142118
}
21152119
catch (ObjectDisposedException)
21162120
{
@@ -2147,6 +2151,9 @@ await InvokeRpcAsync<object>(
21472151
[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to fetch tool metadata for {toolName}")]
21482152
private partial void LogToolMetadataFetchFailed(Exception exception, string toolName);
21492153

2154+
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to detach session {sessionId}: {error}")]
2155+
private partial void LogSessionDetachFailed(string sessionId, string error);
2156+
21502157
[LoggerMessage(Level = LogLevel.Error, Message = "Permission handler or response delivery failed. SessionId={SessionId}, RequestId={RequestId}")]
21512158
private partial void LogPermissionHandlerOrDeliveryFailed(Exception exception, string sessionId, string requestId);
21522159

@@ -2184,11 +2191,17 @@ internal record SessionAbortRequest
21842191
public string SessionId { get; init; } = string.Empty;
21852192
}
21862193

2187-
internal record SessionDestroyRequest
2194+
internal record SessionDetachRequest
21882195
{
21892196
public string SessionId { get; init; } = string.Empty;
21902197
}
21912198

2199+
internal record SessionDetachResponse
2200+
{
2201+
public bool Success { get; init; }
2202+
public string? Error { get; init; }
2203+
}
2204+
21922205
internal void ThrowIfDisposed()
21932206
{
21942207
ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this);
@@ -2221,7 +2234,8 @@ internal void ThrowIfDisposed()
22212234
[JsonSerializable(typeof(SendMessageRequest))]
22222235
[JsonSerializable(typeof(SendMessageResponse))]
22232236
[JsonSerializable(typeof(SessionAbortRequest))]
2224-
[JsonSerializable(typeof(SessionDestroyRequest))]
2237+
[JsonSerializable(typeof(SessionDetachRequest))]
2238+
[JsonSerializable(typeof(SessionDetachResponse))]
22252239
[JsonSerializable(typeof(SessionEndHookInput))]
22262240
[JsonSerializable(typeof(SessionEndHookOutput))]
22272241
[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/E2E/SessionE2ETests.cs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,50 @@ public async Task Resumes_A_Persisted_Session_From_A_New_Client_When_An_Mcp_OAut
295295
Assert.Equal(sessionId, session2.SessionId);
296296
}
297297

298+
[Fact]
299+
public async Task Should_Recover_Marker_After_Cold_Resume_With_Explicit_Session_Id()
300+
{
301+
await using var isolatedCtx = await E2ETestContext.CreateAsync();
302+
await isolatedCtx.ConfigureForTestAsync("session", nameof(Should_Recover_Marker_After_Cold_Resume_With_Explicit_Session_Id));
303+
304+
var sessionId = $"e2e-cold-resume-{Guid.NewGuid()}";
305+
306+
var client1 = isolatedCtx.CreateClient();
307+
var session1 = await isolatedCtx.CreateSessionAsync(client1, new SessionConfig
308+
{
309+
SessionId = sessionId,
310+
OnPermissionRequest = PermissionHandler.ApproveAll,
311+
});
312+
Assert.Equal(sessionId, session1.SessionId);
313+
314+
var answer = await session1.SendAndWaitAsync(new MessageOptions
315+
{
316+
Prompt = "Please remember this exact secret marker for later - MARKER-7f3ac21e. Reply with only the single word \"Acknowledged\".",
317+
});
318+
Assert.NotNull(answer);
319+
Assert.Contains("Acknowledged", answer!.Data.Content ?? string.Empty);
320+
321+
await session1.DisposeAsync();
322+
await client1.ForceStopAsync();
323+
324+
var client2 = isolatedCtx.CreateClient();
325+
var session2 = await isolatedCtx.ResumeSessionAsync(client2, sessionId, new ResumeSessionConfig
326+
{
327+
OnPermissionRequest = PermissionHandler.ApproveAll,
328+
});
329+
Assert.Equal(sessionId, session2.SessionId);
330+
331+
var answer2 = await session2.SendAndWaitAsync(new MessageOptions
332+
{
333+
Prompt = "What was the exact secret marker I asked you to remember earlier? Reply with only that marker value and nothing else.",
334+
});
335+
Assert.NotNull(answer2);
336+
Assert.Contains("MARKER-7f3ac21e", answer2!.Data.Content ?? string.Empty);
337+
338+
await session2.DisposeAsync();
339+
await client2.ForceStopAsync();
340+
}
341+
298342
[Fact]
299343
public async Task Should_Throw_Error_When_Resuming_Non_Existent_Session()
300344
{

‎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 releases 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
@@ -595,7 +595,7 @@ private static async Task StopClientForCleanupAsync(CopilotClient client)
595595
$"Graceful in-process client cleanup exceeded {s_gracefulClientStopTimeout}; forcing shutdown.");
596596
await client.ForceStopAsync();
597597

598-
// Disposing the connection completes any session.destroy RPC that
598+
// Disposing the connection completes any session.detach RPC that
599599
// blocked graceful cleanup. Observe that task before continuing.
600600
await gracefulStop.WaitAsync(s_gracefulClientStopTimeout);
601601
}

‎dotnet/test/Unit/ClientSessionLifetimeTests.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1950,7 +1950,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
19501950
{
19511951
["success"] = true
19521952
},
1953-
"session.destroy" => await DestroySessionAsync(cancellationToken),
1953+
"session.detach" => await DetachSessionAsync(cancellationToken),
19541954
"runtime.shutdown" => HandleRuntimeShutdown(),
19551955
_ => throw new InvalidOperationException($"Unexpected RPC method '{method}'.")
19561956
};
@@ -1987,15 +1987,15 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
19871987
};
19881988
}
19891989

1990-
private async Task<Dictionary<string, object?>> DestroySessionAsync(CancellationToken cancellationToken)
1990+
private async Task<Dictionary<string, object?>> DetachSessionAsync(CancellationToken cancellationToken)
19911991
{
19921992
if (_delayDestroy)
19931993
{
19941994
_destroyStarted.TrySetResult();
19951995
await _allowDestroy.Task.WaitAsync(cancellationToken);
19961996
}
19971997

1998-
return [];
1998+
return new Dictionary<string, object?> { ["success"] = true };
19991999
}
20002000

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

‎dotnet/test/Unit/GitHubTelemetryTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
495495
"session.create" => CaptureCreate(request),
496496
"session.resume" => CaptureResume(request),
497497
"session.send" => new Dictionary<string, object?> { ["messageId"] = "message-1" },
498-
"session.destroy" => new Dictionary<string, object?>(),
498+
"session.detach" => new Dictionary<string, object?> { ["success"] = true },
499499
"session.options.update" => new Dictionary<string, object?> { ["success"] = true },
500500
"runtime.shutdown" => new Dictionary<string, object?>(),
501501
_ => 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
@@ -2917,8 +2917,10 @@ func serveInMemoryRuntime(t *testing.T, stdinR *io.PipeReader, stdoutW *io.PipeW
29172917
result = map[string]any{"id": "interest-1"}
29182918
case "session.options.update":
29192919
result = map[string]any{"success": true}
2920-
case "session.skills.reload", "session.destroy":
2920+
case "session.skills.reload":
29212921
result = map[string]any{}
2922+
case "session.detach":
2923+
result = map[string]any{"success": true}
29222924
default:
29232925
t.Errorf("unexpected JSON-RPC method %s", request.Method)
29242926
return

‎go/github_token_provider_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ func TestGitHubTokenProviderCreateRequestAndCallback(t *testing.T) {
4848
sessionID := sessionIDFromParams(t, params)
4949
return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil
5050
})
51-
server.SetRequestHandler("session.destroy", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
52-
return []byte(`{}`), nil
51+
server.SetRequestHandler("session.detach", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
52+
return []byte(`{"success":true}`), nil
5353
})
5454

5555
var gotArgs GitHubTokenProviderArgs
@@ -201,8 +201,8 @@ func TestGitHubTokenStringRedactsAccessToken(t *testing.T) {
201201
func TestGitHubTokenProviderCleanupOnDisconnectError(t *testing.T) {
202202
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
203203
t.Cleanup(server.Stop)
204-
server.SetRequestHandler("session.destroy", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
205-
return nil, &jsonrpc2.Error{Code: -32000, Message: "destroy failed"}
204+
server.SetRequestHandler("session.detach", func(json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
205+
return nil, &jsonrpc2.Error{Code: -32000, Message: "detach failed"}
206206
})
207207
client := &Client{}
208208
registrationID := client.registerGitHubTokenProvider(func(GitHubTokenProviderArgs) (*GitHubTokenProviderResult, error) {
@@ -213,7 +213,7 @@ func TestGitHubTokenProviderCleanupOnDisconnectError(t *testing.T) {
213213
client.unregisterGitHubTokenProvider(registrationID)
214214
})
215215

216-
if err := session.Disconnect(); err == nil || !strings.Contains(err.Error(), "destroy failed") {
216+
if err := session.Disconnect(); err == nil || !strings.Contains(err.Error(), "detach failed") {
217217
t.Fatalf("Disconnect error = %v", err)
218218
}
219219
if len(client.gitHubTokenProviders) != 0 {

‎go/internal/e2e/client_options_e2e_test.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,10 @@ function handleMessage(message) {
869869
writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null });
870870
return;
871871
}
872+
if (message.method === "session.detach") {
873+
writeResponse(message.id, { success: true });
874+
return;
875+
}
872876
if (message.method === "session.resume") {
873877
const sessionId = (message.params && message.params.sessionId) || "fake-session";
874878
writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null });

0 commit comments

Comments
 (0)