Skip to content

Commit e3f21ae

Browse files
committed
Fix .NET in-process E2E transport
Make the in-process E2E matrix use the FFI runtime, restore the native host environment between tests, and align same-client session resume with the other SDKs while preserving routing after failed resumes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c71188a1-1445-46aa-9faf-3b73cf6a6dd9
1 parent ff18f7e commit e3f21ae

8 files changed

Lines changed: 201 additions & 49 deletions

File tree

dotnet/src/Client.cs

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -774,8 +774,11 @@ private CopilotSession InitializeSession(
774774
SessionConfigBase config,
775775
Dictionary<string, Func<string, Task<string>>>? transformCallbacks,
776776
bool hasHooks,
777-
string callerName)
777+
string callerName,
778+
bool replaceExisting,
779+
out CopilotSession? replacedSession)
778780
{
781+
replacedSession = null;
779782
var setupTimestamp = Stopwatch.GetTimestamp();
780783
var session = new CopilotSession(
781784
sessionId,
@@ -808,7 +811,24 @@ private CopilotSession InitializeSession(
808811
ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider);
809812
session.SetCanvasHandler(config.CanvasHandler);
810813
session.RegisterBearerTokenProviders(BuildBearerTokenCallbacks(config));
811-
RegisterSession(session);
814+
if (replaceExisting)
815+
{
816+
CopilotSession? displacedSession = null;
817+
_sessions.AddOrUpdate(
818+
session.SessionId,
819+
session,
820+
(_, current) =>
821+
{
822+
displacedSession = current;
823+
return session;
824+
});
825+
replacedSession = displacedSession;
826+
}
827+
else if (!_sessions.TryAdd(session.SessionId, session))
828+
{
829+
throw new InvalidOperationException($"Session '{session.SessionId}' is already tracked by this client.");
830+
}
831+
812832
session.StartProcessingEvents();
813833
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
814834
callerName + " local setup complete. Elapsed={Elapsed}, SessionId={SessionId}, Tools={ToolsCount}, Commands={CommandsCount}, Hooks={HasHooks}",
@@ -1120,7 +1140,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
11201140
config,
11211141
transformCallbacks,
11221142
hasHooks,
1123-
"CopilotClient.CreateSessionAsync");
1143+
"CopilotClient.CreateSessionAsync",
1144+
replaceExisting: false,
1145+
out _);
11241146
}
11251147
try
11261148
{
@@ -1220,7 +1242,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
12201242
config,
12211243
transformCallbacks,
12221244
hasHooks,
1223-
"CopilotClient.CreateSessionAsync");
1245+
"CopilotClient.CreateSessionAsync",
1246+
replaceExisting: false,
1247+
out _);
12241248
}
12251249
};
12261250

@@ -1286,6 +1310,9 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
12861310
/// <remarks>
12871311
/// This allows you to continue a previous conversation, maintaining all conversation history.
12881312
/// The session must have been previously created and not deleted.
1313+
/// If this client already tracks the session, the returned instance replaces the previous
1314+
/// <see cref="CopilotSession"/> for event and request routing. Existing references to the
1315+
/// previous instance remain usable, but no longer receive routed events or requests.
12891316
/// </remarks>
12901317
/// <example>
12911318
/// <code>
@@ -1332,7 +1359,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
13321359
config,
13331360
transformCallbacks,
13341361
hasHooks,
1335-
"CopilotClient.ResumeSessionAsync");
1362+
"CopilotClient.ResumeSessionAsync",
1363+
replaceExisting: true,
1364+
out var previousSession);
13361365
try
13371366
{
13381367
var (traceparent, tracestate) = TelemetryHelpers.GetTraceContext();
@@ -1431,7 +1460,15 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
14311460
}
14321461
catch (Exception ex)
14331462
{
1434-
session.RemoveFromClient();
1463+
if (previousSession is null)
1464+
{
1465+
session.RemoveFromClient();
1466+
}
1467+
else
1468+
{
1469+
_sessions.TryUpdate(sessionId, previousSession, session);
1470+
}
1471+
14351472
if (ex is not OperationCanceledException)
14361473
{
14371474
LoggingHelpers.LogTiming(_logger, LogLevel.Warning, ex,
@@ -2476,14 +2513,6 @@ private static JsonSerializerOptions CreateSerializerOptions()
24762513
return session;
24772514
}
24782515

2479-
private void RegisterSession(CopilotSession session)
2480-
{
2481-
if (!_sessions.TryAdd(session.SessionId, session))
2482-
{
2483-
throw new InvalidOperationException($"Session '{session.SessionId}' is already tracked by this client.");
2484-
}
2485-
}
2486-
24872516
private void RemoveSession(string sessionId)
24882517
{
24892518
_sessions.TryRemove(sessionId, out _);

dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public async Task Should_Return_Null_Or_Empty_Content_For_Unknown_Checkpoint()
2929
{
3030
await using var session = await CreateSessionAsync();
3131

32-
var result = await session.Rpc.Workspaces.ReadCheckpointAsync(long.MaxValue);
32+
var result = await session.Rpc.Workspaces.ReadCheckpointAsync(uint.MaxValue);
3333

3434
Assert.True(string.IsNullOrEmpty(result.Content));
3535
}

dotnet/test/E2E/SessionE2ETests.cs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,17 +226,20 @@ public async Task Should_Create_Session_With_Custom_Tool()
226226
}
227227

228228
[Fact]
229-
public async Task Should_Reject_Resuming_Active_Session_Using_The_Same_Client()
229+
public async Task Should_Replace_Active_Session_When_Resuming_Using_The_Same_Client()
230230
{
231231
var session1 = await CreateSessionAsync();
232232
var sessionId = session1.SessionId;
233233

234-
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
235-
Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
236-
{
237-
OnPermissionRequest = PermissionHandler.ApproveAll,
238-
}));
239-
Assert.Contains(sessionId, exception.Message);
234+
await using var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
235+
{
236+
OnPermissionRequest = PermissionHandler.ApproveAll,
237+
});
238+
239+
Assert.Equal(sessionId, session2.SessionId);
240+
_ = await session1.GetEventsAsync();
241+
242+
await session1.DisposeAsync();
240243
}
241244

242245
[Fact]

dotnet/test/Harness/E2ETestBase.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ internal static string GetTestName(ITestOutputHelper output)
5959

6060
public async Task InitializeAsync()
6161
{
62+
Ctx.PrepareForTest();
6263
await Ctx.CleanupAfterTestAsync();
6364
await Ctx.ConfigureForTestAsync(_snapshotCategory, _testName);
6465
}
@@ -88,14 +89,23 @@ protected async Task<CopilotSession> ResumeSessionAsync(string sessionId, Resume
8889
config ??= new ResumeSessionConfig();
8990
config.OnPermissionRequest ??= PermissionHandler.ApproveAll;
9091

91-
await Client.StartAsync();
92-
var port = Client.RuntimePort
93-
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");
94-
95-
var client = Ctx.CreateClient(options: new CopilotClientOptions
92+
CopilotClient client;
93+
if (E2ETestContext.UsesInProcessTransport)
94+
{
95+
client = Client;
96+
}
97+
else
9698
{
97-
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
98-
});
99+
await Client.StartAsync();
100+
var port = Client.RuntimePort
101+
?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume.");
102+
103+
client = Ctx.CreateClient(options: new CopilotClientOptions
104+
{
105+
Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken),
106+
});
107+
}
108+
99109
return await client.ResumeSessionAsync(sessionId, config);
100110
}
101111

dotnet/test/Harness/E2ETestContext.cs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public sealed class E2ETestContext : IAsyncDisposable
1616
public string HomeDir { get; }
1717
public string WorkDir { get; }
1818
public string ProxyUrl { get; }
19+
internal static bool UsesInProcessTransport => IsInProcess(null);
1920

2021
/// <summary>Optional logger injected by tests; applied to all clients created via <see cref="CreateClient"/>.</summary>
2122
public ILogger? Logger { get; set; }
@@ -298,22 +299,8 @@ public CopilotClient CreateClient(
298299

299300
if (IsInProcess(options.Connection))
300301
{
301-
// In-process hosting: runtime code runs host-side in this process (the
302-
// loaded cdylib) and reads the ambient process environment rather than
303-
// the environment passed to copilot_runtime_host_start, so the per-test
304-
// redirects, cleared tokens/HMAC, and isolated home must be mirrored
305-
// onto this process's real environment. Restored after each test by
306-
// InProcessEnvIsolationAttribute.
307-
foreach (var (name, value) in env)
308-
{
309-
InProcessEnvIsolation.Apply(name, value);
310-
}
311-
312-
// A per-client WorkingDirectory is rejected in-process; instead point this
313-
// process's cwd at the desired directory so the worker inherits it at spawn
314-
// (restored after the test by InProcessEnvIsolationAttribute).
315302
options.WorkingDirectory = null;
316-
InProcessEnvIsolation.SetWorkingDirectory(desiredWorkingDirectory);
303+
ApplyInProcessEnvironment(env, desiredWorkingDirectory);
317304
}
318305
else if (options.Connection is ChildProcessRuntimeConnection child)
319306
{
@@ -352,6 +339,29 @@ public CopilotClient CreateClient(
352339
return client;
353340
}
354341

342+
internal void PrepareForTest()
343+
{
344+
if (UsesInProcessTransport)
345+
{
346+
ApplyInProcessEnvironment(GetEnvironment(), WorkDir);
347+
}
348+
}
349+
350+
private static void ApplyInProcessEnvironment(IReadOnlyDictionary<string, string> environment, string workingDirectory)
351+
{
352+
// Runtime code runs host-side in this process and reads its ambient environment,
353+
// so restore the per-test redirects and isolated home after the assembly-level
354+
// isolation attribute reset them at the end of the preceding test.
355+
foreach (var (name, value) in environment)
356+
{
357+
InProcessEnvIsolation.Apply(name, value);
358+
}
359+
360+
// The worker inherits the host process cwd because the native host has no
361+
// per-client working-directory parameter.
362+
InProcessEnvIsolation.SetWorkingDirectory(workingDirectory);
363+
}
364+
355365
public void UntrackClient(CopilotClient client)
356366
{
357367
lock (_clientsLock)

dotnet/test/Harness/E2ETestFixture.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@ public async Task InitializeAsync()
1919
Ctx = await E2ETestContext.CreateAsync();
2020
Client = Ctx.CreateClient(options: new CopilotClientOptions
2121
{
22-
Connection = RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken),
22+
Connection = CreateSharedConnection(E2ETestContext.UsesInProcessTransport),
2323
}, persistent: true);
2424
}
2525

26+
internal static RuntimeConnection CreateSharedConnection(bool useInProcessTransport) =>
27+
useInProcessTransport
28+
? RuntimeConnection.ForInProcess()
29+
: RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken);
30+
2631
public async Task DisposeAsync()
2732
{
2833
await Ctx.DisposeAsync();

dotnet/test/Unit/ClientSessionLifetimeTests.cs

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,25 +183,64 @@ public async Task StopAsync_Keeps_Session_Rooted_Until_Destroy_Completes()
183183
}
184184

185185
[Fact]
186-
public async Task ResumeSessionAsync_Throws_When_Same_Client_Already_Tracks_Session()
186+
public async Task ResumeSessionAsync_Replaces_Session_Tracked_By_Same_Client()
187187
{
188188
await using var server = await FakeCopilotServer.StartAsync();
189189
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
190190

191191
var sessionId = "same-session-id";
192-
await using var session = await client.CreateSessionAsync(new SessionConfig
192+
var session = await client.CreateSessionAsync(new SessionConfig
193193
{
194194
SessionId = sessionId,
195195
OnPermissionRequest = PermissionHandler.ApproveAll
196196
});
197197
AssertSessionCount(client, sessions: 1);
198198

199-
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
199+
var resumedSession = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
200+
{
201+
OnPermissionRequest = PermissionHandler.ApproveAll
202+
});
203+
204+
Assert.NotSame(session, resumedSession);
205+
AssertSessionCount(client, sessions: 1);
206+
Assert.Same(resumedSession, GetTrackedSession(client, sessionId));
207+
Assert.Equal("message-1", await session.SendAsync("The previous wrapper remains callable."));
208+
Assert.DoesNotContain(server.Requests, request => request.Method == "session.destroy");
209+
210+
await session.DisposeAsync();
211+
AssertSessionCount(client, sessions: 1);
212+
213+
await resumedSession.DisposeAsync();
214+
AssertSessionCount(client, sessions: 0);
215+
Assert.Equal(2, server.Requests.Count(request => request.Method == "session.destroy"));
216+
}
217+
218+
[Fact]
219+
public async Task Failed_ResumeSessionAsync_Restores_Previous_Registration()
220+
{
221+
await using var server = await FakeCopilotServer.StartAsync();
222+
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
223+
224+
var sessionId = "same-session-id";
225+
var session = await client.CreateSessionAsync(new SessionConfig
226+
{
227+
SessionId = sessionId,
228+
OnPermissionRequest = PermissionHandler.ApproveAll
229+
});
230+
server.FailNextResume();
231+
232+
await Assert.ThrowsAsync<IOException>(() => client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
200233
{
201234
OnPermissionRequest = PermissionHandler.ApproveAll
202235
}));
203-
Assert.Contains(sessionId, exception.Message);
236+
204237
AssertSessionCount(client, sessions: 1);
238+
Assert.Same(session, GetTrackedSession(client, sessionId));
239+
Assert.Equal("message-1", await session.SendAsync("The original session remains active."));
240+
241+
await session.DisposeAsync();
242+
AssertSessionCount(client, sessions: 0);
243+
Assert.Single(server.Requests, request => request.Method == "session.destroy");
205244
}
206245

207246
[Fact]
@@ -438,6 +477,13 @@ private static void AssertSessionCount(CopilotClient client, int sessions)
438477
Assert.Equal(sessions, GetPrivateDictionaryCount(client, "_sessions"));
439478
}
440479

480+
private static CopilotSession? GetTrackedSession(CopilotClient client, string sessionId)
481+
{
482+
var method = typeof(CopilotClient).GetMethod("GetSession", BindingFlags.Instance | BindingFlags.NonPublic)
483+
?? throw new InvalidOperationException("GetSession method was not found.");
484+
return (CopilotSession?)method.Invoke(client, [sessionId]);
485+
}
486+
441487
private static int GetPrivateDictionaryCount(CopilotClient client, string fieldName)
442488
{
443489
var field = typeof(CopilotClient).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
@@ -518,6 +564,7 @@ private sealed class FakeCopilotServer : IAsyncDisposable
518564
private string? _lastSessionId;
519565
private bool _delayDestroy;
520566
private bool _failRuntimeShutdown;
567+
private bool _failNextResume;
521568

522569
private FakeCopilotServer(TcpListener listener)
523570
{
@@ -579,6 +626,11 @@ public void FailRuntimeShutdown()
579626
_failRuntimeShutdown = true;
580627
}
581628

629+
public void FailNextResume()
630+
{
631+
_failNextResume = true;
632+
}
633+
582634
public async ValueTask DisposeAsync()
583635
{
584636
_allowDestroy.TrySetResult();
@@ -623,6 +675,22 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
623675

624676
var id = idElement.Clone();
625677
var method = request.GetProperty("method").GetString();
678+
if (method == "session.resume" && _failNextResume)
679+
{
680+
_failNextResume = false;
681+
await WriteMessageAsync(stream, new Dictionary<string, object?>
682+
{
683+
["jsonrpc"] = "2.0",
684+
["id"] = id,
685+
["error"] = new Dictionary<string, object?>
686+
{
687+
["code"] = -32000,
688+
["message"] = "session resume failed"
689+
}
690+
}, cancellationToken);
691+
return;
692+
}
693+
626694
if (method == "runtime.shutdown" && _failRuntimeShutdown)
627695
{
628696
RuntimeShutdownCount++;

0 commit comments

Comments
 (0)