Skip to content

Commit 577df8b

Browse files
stephentoubCopilot
andcommitted
Add graceful runtime shutdown to SDK clients
Call runtime.shutdown during normal client stop for SDK-owned CLI processes across all language SDKs. Add bounded cleanup, timing diagnostics, and lifecycle coverage while preserving force-stop and external-runtime behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 84a0106 commit 577df8b

16 files changed

Lines changed: 942 additions & 226 deletions

File tree

dotnet/src/Client.cs

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
5858
/// </summary>
5959
private const int MinProtocolVersion = 3;
6060
private static readonly TimeSpan s_stderrPumpShutdownTimeout = TimeSpan.FromSeconds(5);
61+
private static readonly TimeSpan s_runtimeShutdownTimeout = TimeSpan.FromSeconds(10);
6162

6263
/// <summary>
6364
/// Provides a thread-safe collection of active Copilot sessions, indexed by session identifier.
@@ -291,7 +292,7 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
291292

292293
if (connection is not null)
293294
{
294-
await CleanupConnectionAsync(connection, errors: null);
295+
await CleanupConnectionAsync(connection, errors: null, gracefulRuntimeShutdown: false);
295296
}
296297
else if (cliProcess is not null)
297298
{
@@ -312,6 +313,7 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
312313
/// This method performs graceful cleanup:
313314
/// <list type="number">
314315
/// <item>Closes all active sessions (releases in-memory resources)</item>
316+
/// <item>Requests runtime shutdown for SDK-owned CLI processes</item>
315317
/// <item>Closes the JSON-RPC connection</item>
316318
/// <item>Terminates the CLI server process (if spawned by this client)</item>
317319
/// </list>
@@ -346,7 +348,7 @@ public async Task StopAsync()
346348

347349
_sessions.Clear();
348350

349-
await CleanupConnectionAsync(errors);
351+
await CleanupConnectionAsync(errors, gracefulRuntimeShutdown: true);
350352

351353
ThrowErrors(errors);
352354
}
@@ -378,7 +380,7 @@ public async Task ForceStopAsync()
378380
_sessions.Clear();
379381

380382
var errors = new List<Exception>();
381-
await CleanupConnectionAsync(errors);
383+
await CleanupConnectionAsync(errors, gracefulRuntimeShutdown: false);
382384
ThrowErrors(errors);
383385
}
384386

@@ -398,7 +400,7 @@ private static void ThrowErrors(List<Exception>? errors)
398400
}
399401
}
400402

401-
private async Task CleanupConnectionAsync(List<Exception>? errors)
403+
private async Task CleanupConnectionAsync(List<Exception>? errors, bool gracefulRuntimeShutdown)
402404
{
403405
var connectionTask = _connectionTask;
404406
if (connectionTask is null)
@@ -419,11 +421,32 @@ private async Task CleanupConnectionAsync(List<Exception>? errors)
419421
return;
420422
}
421423

422-
await CleanupConnectionAsync(ctx, errors);
424+
await CleanupConnectionAsync(ctx, errors, gracefulRuntimeShutdown);
423425
}
424426

425-
private async Task CleanupConnectionAsync(Connection ctx, List<Exception>? errors)
427+
private async Task CleanupConnectionAsync(Connection ctx, List<Exception>? errors, bool gracefulRuntimeShutdown)
426428
{
429+
var runtimeShutdownCompleted = false;
430+
if (gracefulRuntimeShutdown && ctx.CliProcess is not null)
431+
{
432+
var runtimeShutdownTimestamp = Stopwatch.GetTimestamp();
433+
try
434+
{
435+
using var cancellation = new CancellationTokenSource(s_runtimeShutdownTimeout);
436+
await ctx.Server.Runtime.ShutdownAsync(cancellation.Token);
437+
runtimeShutdownCompleted = true;
438+
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
439+
"CopilotClient.StopAsync runtime shutdown complete. Elapsed={Elapsed}",
440+
runtimeShutdownTimestamp);
441+
}
442+
catch (Exception ex)
443+
{
444+
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, ex,
445+
"CopilotClient.StopAsync runtime shutdown failed. Elapsed={Elapsed}",
446+
runtimeShutdownTimestamp);
447+
}
448+
}
449+
427450
try { ctx.Rpc.Dispose(); }
428451
catch (Exception ex) { AddCleanupError(errors, ex, _logger); }
429452

@@ -439,22 +462,62 @@ private async Task CleanupConnectionAsync(Connection ctx, List<Exception>? error
439462

440463
if (ctx.CliProcess is { } childProcess)
441464
{
442-
await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger);
465+
await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger, runtimeShutdownCompleted);
443466
}
444467
}
445468

446-
private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List<Exception>? errors, ILogger? logger)
469+
private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List<Exception>? errors, ILogger? logger, bool waitForGracefulExit = false)
447470
{
448471
stderrPump?.Cancel();
449472

450473
try
451474
{
452475
if (!childProcess.HasExited)
453476
{
477+
if (waitForGracefulExit)
478+
{
479+
var shutdownWaitTimestamp = Stopwatch.GetTimestamp();
480+
try
481+
{
482+
await childProcess.WaitForExitAsync().WaitAsync(s_runtimeShutdownTimeout);
483+
}
484+
catch (TimeoutException ex)
485+
{
486+
if (logger is not null)
487+
{
488+
LoggingHelpers.LogTiming(logger, LogLevel.Debug, ex,
489+
"Timed out waiting for runtime process to exit after graceful shutdown. Elapsed={Elapsed}, Timeout={Timeout}",
490+
shutdownWaitTimestamp,
491+
s_runtimeShutdownTimeout);
492+
}
493+
}
494+
}
495+
496+
if (childProcess.HasExited)
497+
{
498+
return;
499+
}
500+
454501
childProcess.Kill(entireProcessTree: true);
455502
// Kill is asynchronous; wait for the root CLI process to exit so cleanup callers
456503
// do not observe StopAsync/DisposeAsync completion while it is still tearing down.
457-
await childProcess.WaitForExitAsync();
504+
var killWaitTimestamp = Stopwatch.GetTimestamp();
505+
try
506+
{
507+
await childProcess.WaitForExitAsync().WaitAsync(s_runtimeShutdownTimeout);
508+
}
509+
catch (TimeoutException ex)
510+
{
511+
if (logger is not null)
512+
{
513+
LoggingHelpers.LogTiming(logger, LogLevel.Debug, ex,
514+
"Timed out waiting for runtime process to exit after kill. Elapsed={Elapsed}, Timeout={Timeout}",
515+
killWaitTimestamp,
516+
s_runtimeShutdownTimeout);
517+
}
518+
519+
AddCleanupError(errors, ex, logger);
520+
}
458521
}
459522
}
460523
catch (Exception ex)
@@ -2002,9 +2065,10 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
20022065
"CopilotClient.ConnectToServerAsync transport setup complete. Elapsed={Elapsed}",
20032066
setupTimestamp);
20042067

2005-
_serverRpc = new ServerRpc(rpc);
2068+
var connection = new Connection(rpc, cliProcess, networkStream, stderrPump);
2069+
_serverRpc = connection.Server;
20062070

2007-
return new Connection(rpc, cliProcess, networkStream, stderrPump);
2071+
return connection;
20082072
}
20092073
catch
20102074
{
@@ -2208,6 +2272,7 @@ private class Connection(
22082272
{
22092273
public Process? CliProcess => cliProcess;
22102274
public JsonRpc Rpc => rpc;
2275+
public ServerRpc Server => field ?? Interlocked.CompareExchange(ref field, new(rpc), null) ?? field;
22112276
public NetworkStream? NetworkStream => networkStream;
22122277
public ProcessStderrPump? StderrPump => stderrPump;
22132278
public StringBuilder? StderrBuffer => stderrPump?.Buffer;

dotnet/test/E2E/TelemetryExportE2ETests.cs

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,7 @@ public async Task Should_Export_File_Telemetry_For_Sdk_Interactions()
4747
await session.DisposeAsync();
4848
await client.StopAsync();
4949

50-
var entries = await ReadTelemetryEntriesAsync(
51-
telemetryPath,
52-
entries => entries.Any(entry => GetTypeName(entry) == "span" &&
53-
GetStringAttribute(entry, "gen_ai.operation.name") == "invoke_agent"));
50+
var entries = await ReadTelemetryEntriesAsync(telemetryPath);
5451
var spans = entries.Where(entry => GetTypeName(entry) == "span").ToList();
5552

5653
Assert.NotEmpty(spans);
@@ -89,46 +86,23 @@ public async Task Should_Export_File_Telemetry_For_Sdk_Interactions()
8986
static string EchoTelemetryMarker(string value) => value;
9087
}
9188

92-
private static async Task<IReadOnlyList<JsonElement>> ReadTelemetryEntriesAsync(
93-
string path,
94-
Func<IReadOnlyList<JsonElement>, bool> isComplete)
89+
private static async Task<IReadOnlyList<JsonElement>> ReadTelemetryEntriesAsync(string path)
9590
{
96-
IReadOnlyList<JsonElement> entries = [];
97-
await TestHelper.WaitForConditionAsync(
98-
async () =>
99-
{
100-
entries = await ReadTelemetryEntriesOnceAsync(path);
101-
return entries.Count > 0 && isComplete(entries);
102-
},
103-
timeout: TimeSpan.FromSeconds(30),
104-
timeoutMessage: $"Timed out waiting for telemetry records in '{path}'.",
105-
transientExceptionFilter: exception => TestHelper.IsTransientFileSystemException(exception) || exception is JsonException);
106-
107-
return entries;
108-
109-
static async Task<IReadOnlyList<JsonElement>> ReadTelemetryEntriesOnceAsync(string path)
91+
var entries = new List<JsonElement>();
92+
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
93+
using var reader = new StreamReader(stream);
94+
while (await reader.ReadLineAsync() is { } line)
11095
{
111-
if (!File.Exists(path) || new FileInfo(path).Length == 0)
96+
if (string.IsNullOrWhiteSpace(line))
11297
{
113-
return [];
98+
continue;
11499
}
115100

116-
var entries = new List<JsonElement>();
117-
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
118-
using var reader = new StreamReader(stream);
119-
while (await reader.ReadLineAsync() is { } line)
120-
{
121-
if (string.IsNullOrWhiteSpace(line))
122-
{
123-
continue;
124-
}
125-
126-
using var document = JsonDocument.Parse(line);
127-
entries.Add(document.RootElement.Clone());
128-
}
129-
130-
return entries;
101+
using var document = JsonDocument.Parse(line);
102+
entries.Add(document.RootElement.Clone());
131103
}
104+
105+
return entries;
132106
}
133107

134108
private static string? GetTraceId(JsonElement entry) => GetStringProperty(entry, "traceId");

0 commit comments

Comments
 (0)