Skip to content

Commit 824bf9f

Browse files
Update SDK snapshot for Copilot CLI 1.0.90-0
1 parent 9b300dc commit 824bf9f

48 files changed

Lines changed: 1504 additions & 139 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CONTRIBUTING.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,16 @@ follow [the Rust SDK workflow](.github/workflows/sdk-rust.yml) for rustdoc.
294294

295295
### Recording and replaying SDK tests
296296

297+
Owned-stdio shutdown regressions share
298+
`test/harness/stdio-shutdown-runtime.cjs` across all six SDKs. Launch it with
299+
Node and arguments `<cleanup-marker> <mode> <pid-file>`. The fixture acknowledges
300+
`runtime.shutdown`, but writes its cleanup marker only after stdin EOF, matching
301+
the native wrapper's host-finalization boundary. Language-native tests exercise
302+
graceful stop/disposal, force-stop where exposed, a child that ignores EOF, and
303+
failed-startup cleanup. Keep those lifecycle expectations aligned when changing
304+
an SDK transport; test watchdogs must allow all cleanup phases their separate
305+
budgets, rather than treating the graceful-exit timeout as a total shutdown cap.
306+
297307
The shared harness records real inference responses under `test/snapshots`.
298308
Record new captures with `GITHUB_TOKEN` set and `GITHUB_ACTIONS` unset;
299309
never author model responses by hand. Rerun with `GITHUB_ACTIONS=true` and real

‎dotnet/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ Start the CLI server and establish connection.
166166
##### `StopAsync(): Task`
167167

168168
Stop the server and close all sessions. Throws if errors are encountered during cleanup.
169+
For an owned stdio runtime, graceful shutdown closes stdin and waits up to 10 seconds
170+
for host cleanup, including telemetry export. This cleanup is best-effort: if the wait
171+
times out, the process is terminated and that timeout alone is not reported as a cleanup
172+
error. A successful return does not guarantee that all telemetry was exported.
169173

170174
##### `ForceStopAsync(): Task`
171175

@@ -195,6 +199,11 @@ Create a new conversation session.
195199
- `OnUserInputRequest` - Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section.
196200
- `AskUserVariant` - Selects the model-facing `ask_user` tool shape. Defaults to `AskUserVariant.Legacy`; use `AskUserVariant.Elicitation` with `OnElicitationRequest`.
197201
- `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section.
202+
- `CanvasHandler` - Handles canvas open, close, and action callbacks. The SDK awaits
203+
asynchronous callbacks before replying, including callbacks without a result, unless
204+
the runtime cancels the request first. A cancellation response can be sent while the
205+
callback is still running. Their cancellation token is canceled by a per-request
206+
`$/cancelRequest`, when the runtime connection closes, or when the client is disposed.
198207

199208
##### `ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null): Task<CopilotSession>`
200209

‎dotnet/src/Client.cs‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,7 @@ or IOException
692692

693693
if (ctx.CliProcess is { } childProcess)
694694
{
695-
await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger);
695+
await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger, gracefulRuntimeShutdown);
696696
}
697697

698698
if (ctx.FfiHost is { } ffiHost)
@@ -703,20 +703,35 @@ or IOException
703703
}
704704
}
705705

706-
private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List<Exception>? errors, ILogger? logger)
706+
private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List<Exception>? errors, ILogger? logger, bool gracefulRuntimeShutdown = false)
707707
{
708708
var processExited = false;
709709

710710
try
711711
{
712+
if (gracefulRuntimeShutdown && childProcess.StartInfo.RedirectStandardInput && !childProcess.HasExited)
713+
{
714+
try
715+
{
716+
// The native wrapper finalizes host telemetry after stdin EOF,
717+
// not when it acknowledges runtime.shutdown.
718+
childProcess.StandardInput.Close();
719+
await childProcess.WaitForExitAsync().WaitAsync(s_runtimeShutdownTimeout);
720+
}
721+
catch (Exception ex) when (ex is TimeoutException or IOException or ObjectDisposedException)
722+
{
723+
logger?.LogDebug(ex, "Graceful stdio runtime exit did not complete; terminating the process");
724+
}
725+
catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException)
726+
{
727+
AddCleanupError(errors, ex, logger);
728+
}
729+
}
730+
712731
if (!childProcess.HasExited)
713732
{
714-
// The runtime completes all cleanup before responding to
715-
// runtime.shutdown and then leaves termination to us; it
716-
// deliberately keeps its JSON-RPC server alive to send the
717-
// response and never self-exits. Waiting for a self-exit that
718-
// will never come just wastes time, so terminate the child
719-
// immediately and only wait to reap it.
733+
// Force-stop, failed startup, and runtimes that ignore EOF still
734+
// require explicit termination.
720735
childProcess.Kill(entireProcessTree: true);
721736
// Kill is asynchronous; wait for the root CLI process to exit so cleanup callers
722737
// do not observe StopAsync/DisposeAsync completion while it is still tearing down.

‎dotnet/src/Generated/Rpc.cs‎

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dotnet/src/Generated/SessionEvents.cs‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dotnet/src/JsonRpc.cs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -852,15 +852,22 @@ await SendErrorResponseAsync(
852852
{
853853
var result = registration.Handler.DynamicInvoke(invokeArgs);
854854

855-
// Handlers return one of: a synchronous value, Task (void async), or ValueTask<T>.
855+
// Handlers return a synchronous value, Task, ValueTask, or ValueTask<T>.
856856
if (result is Task task)
857857
{
858858
// Task<T> handlers are not supported — use ValueTask<T> for results.
859-
Debug.Assert(!task.GetType().IsGenericType, "Task<T> handlers are not supported; use ValueTask<T>.");
859+
// An async Task method can return a generic runtime state-machine box.
860+
Debug.Assert(registration.Handler.Method.ReturnType == typeof(Task), "Task<T> handlers are not supported; use ValueTask<T>.");
860861
await task.ConfigureAwait(false);
861862
return null;
862863
}
863864

865+
if (result is ValueTask valueTask)
866+
{
867+
await valueTask.ConfigureAwait(false);
868+
return null;
869+
}
870+
864871
if (result is not null && registration.ValueTaskAsTaskMethod is { } valueTaskAsTaskMethod)
865872
{
866873
var asTask = (Task)valueTaskAsTaskMethod.Invoke(result, null)!;

‎dotnet/test/Harness/E2ETestBase.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ protected static Dictionary<string, McpServerConfig> CreateTestMcpServers(params
202202
});
203203
}
204204

205-
protected static string FindTestHarnessDir()
205+
protected internal static string FindTestHarnessDir()
206206
{
207207
var relativePath = Path.Join("test", "harness", "test-mcp-server.mjs");
208208
var dir = new DirectoryInfo(AppContext.BaseDirectory);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
#if NET8_0_OR_GREATER
6+
using GitHub.Copilot.Rpc;
7+
using Xunit;
8+
9+
namespace GitHub.Copilot.Test.Unit;
10+
11+
public sealed partial class ClientSessionLifetimeTests
12+
{
13+
[Theory]
14+
[InlineData(false)]
15+
[InlineData(true)]
16+
public async Task CanvasClose_Awaits_Handler_Completion_And_Propagates_Errors(bool fail)
17+
{
18+
await using var server = await FakeCopilotServer.StartAsync();
19+
server.ResponseFactory = _ => new Dictionary<string, object?>();
20+
await using var client = new CopilotClient(new CopilotClientOptions
21+
{
22+
Connection = RuntimeConnection.ForUri(server.Url)
23+
});
24+
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
25+
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
26+
await using var session = await client.CreateSessionAsync(new SessionConfig
27+
{
28+
CanvasHandler = new CloseCallbackCanvasHandler(async token =>
29+
{
30+
started.SetResult();
31+
await release.Task.WaitAsync(token);
32+
if (fail)
33+
{
34+
throw new InvalidOperationException("close handler failed");
35+
}
36+
})
37+
});
38+
var close = server.SendRequestAsync("canvas.close", CanvasCloseParams(session));
39+
try
40+
{
41+
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
42+
// The ping response fences dispatch of the earlier canvas callback.
43+
await client.PingAsync().WaitAsync(TimeSpan.FromSeconds(5));
44+
Assert.False(close.IsCompleted);
45+
release.SetResult();
46+
47+
if (fail)
48+
{
49+
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
50+
close.WaitAsync(TimeSpan.FromSeconds(5)));
51+
Assert.Contains("close handler failed", error.Message);
52+
}
53+
else
54+
{
55+
await close.WaitAsync(TimeSpan.FromSeconds(5));
56+
}
57+
}
58+
finally
59+
{
60+
release.TrySetResult();
61+
}
62+
}
63+
64+
[Theory]
65+
[InlineData(false)]
66+
[InlineData(true)]
67+
public async Task CanvasClose_Cancels_Handler_When_Connection_Closes(bool disposeClient)
68+
{
69+
await using var server = await FakeCopilotServer.StartAsync();
70+
server.ResponseFactory = _ => new Dictionary<string, object?>();
71+
await using var client = new CopilotClient(new CopilotClientOptions
72+
{
73+
Connection = RuntimeConnection.ForUri(server.Url)
74+
});
75+
var started = new TaskCompletionSource<CancellationToken>(TaskCreationOptions.RunContinuationsAsynchronously);
76+
var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
77+
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
78+
await using var session = await client.CreateSessionAsync(new SessionConfig
79+
{
80+
CanvasHandler = new CloseCallbackCanvasHandler(async token =>
81+
{
82+
using var registration = token.Register(() => cancelled.TrySetResult());
83+
started.SetResult(token);
84+
await release.Task;
85+
})
86+
});
87+
var close = server.SendRequestAsync("canvas.close", CanvasCloseParams(session));
88+
_ = close.ContinueWith(
89+
static task => _ = task.Exception,
90+
CancellationToken.None,
91+
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
92+
TaskScheduler.Default);
93+
try
94+
{
95+
var token = await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
96+
await client.PingAsync().WaitAsync(TimeSpan.FromSeconds(5));
97+
Assert.False(token.IsCancellationRequested);
98+
99+
if (disposeClient)
100+
{
101+
await client.DisposeAsync();
102+
}
103+
else
104+
{
105+
server.CloseConnection();
106+
}
107+
108+
await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5));
109+
Assert.True(token.IsCancellationRequested);
110+
}
111+
finally
112+
{
113+
release.TrySetResult();
114+
}
115+
}
116+
117+
private static Dictionary<string, object?> CanvasCloseParams(CopilotSession session) => new()
118+
{
119+
["sessionId"] = session.SessionId,
120+
["canvasId"] = "test-canvas",
121+
["instanceId"] = "test-instance"
122+
};
123+
124+
private sealed class CloseCallbackCanvasHandler(Func<CancellationToken, Task> close) : CanvasHandlerBase
125+
{
126+
public override Task<CanvasProviderOpenResult> OnOpenAsync(
127+
CanvasProviderOpenRequest context, CancellationToken cancellationToken) =>
128+
Task.FromResult(new CanvasProviderOpenResult { Status = "ready" });
129+
130+
public override Task OnCloseAsync(
131+
CanvasProviderCloseRequest context, CancellationToken cancellationToken) =>
132+
close(cancellationToken);
133+
}
134+
}
135+
#endif
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
#if NET8_0_OR_GREATER
6+
using System.Diagnostics;
7+
using GitHub.Copilot.Test.Harness;
8+
using Xunit;
9+
10+
namespace GitHub.Copilot.Test.Unit;
11+
12+
public sealed class StdioShutdownTests
13+
{
14+
[Theory]
15+
[InlineData("stop")]
16+
[InlineData("dispose")]
17+
[InlineData("force")]
18+
[InlineData("fallback")]
19+
[InlineData("start-failure")]
20+
public async Task Owned_Stdio_Runtime_Finishes_Host_Cleanup_Before_Graceful_Stop_Returns(string operation)
21+
{
22+
var directory = Path.Combine(Path.GetTempPath(), $"copilot-shutdown-{Guid.NewGuid():N}");
23+
Directory.CreateDirectory(directory);
24+
var script = Path.Combine(E2ETestBase.FindTestHarnessDir(), "stdio-shutdown-runtime.cjs");
25+
var marker = Path.Combine(directory, "telemetry.jsonl");
26+
var pidPath = Path.Combine(directory, "runtime.pid");
27+
28+
try
29+
{
30+
await using var client = new CopilotClient(new CopilotClientOptions
31+
{
32+
Connection = RuntimeConnection.ForStdio(path: "node", args: [script, marker, operation, pidPath]),
33+
UseLoggedInUser = false,
34+
});
35+
if (operation == "start-failure")
36+
{
37+
var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
38+
client.StartAsync().WaitAsync(TimeSpan.FromSeconds(5)));
39+
Assert.Contains("protocol version mismatch", error.Message);
40+
var pid = int.Parse(await File.ReadAllTextAsync(pidPath), System.Globalization.CultureInfo.InvariantCulture);
41+
Assert.Throws<ArgumentException>(() => Process.GetProcessById(pid));
42+
Assert.False(File.Exists(marker));
43+
return;
44+
}
45+
46+
await client.StartAsync().WaitAsync(TimeSpan.FromSeconds(5));
47+
using var process = Process.GetProcessById(
48+
int.Parse(await File.ReadAllTextAsync(pidPath), System.Globalization.CultureInfo.InvariantCulture));
49+
var elapsed = Stopwatch.StartNew();
50+
51+
switch (operation)
52+
{
53+
case "stop":
54+
await client.StopAsync().WaitAsync(TimeSpan.FromSeconds(5));
55+
break;
56+
case "dispose":
57+
await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));
58+
break;
59+
case "fallback":
60+
// Allow shutdown RPC, graceful exit, kill/reap, and stderr drain their separate budgets.
61+
await client.StopAsync().WaitAsync(TimeSpan.FromSeconds(40));
62+
Assert.True(elapsed.Elapsed >= TimeSpan.FromSeconds(10),
63+
"Graceful stop must wait for its exit timeout before terminating the child.");
64+
break;
65+
default:
66+
await client.ForceStopAsync().WaitAsync(TimeSpan.FromSeconds(5));
67+
break;
68+
}
69+
70+
Assert.True(process.HasExited);
71+
await client.DisposeAsync();
72+
await client.DisposeAsync();
73+
if (operation == "force")
74+
{
75+
Assert.False(File.Exists(marker));
76+
}
77+
else
78+
{
79+
Assert.Equal("{\"type\":\"span\"}\n", await File.ReadAllTextAsync(marker));
80+
}
81+
}
82+
finally
83+
{
84+
Directory.Delete(directory, recursive: true);
85+
}
86+
}
87+
}
88+
#endif

0 commit comments

Comments
 (0)