diff --git a/dotnet/test/E2E/CommandsE2ETests.cs b/dotnet/test/E2E/CommandsE2ETests.cs index 20db2d7cb..ce8ddfffa 100644 --- a/dotnet/test/E2E/CommandsE2ETests.cs +++ b/dotnet/test/E2E/CommandsE2ETests.cs @@ -202,8 +202,9 @@ public async Task Session_With_Commands_Creates_Successfully() [Fact] public async Task Session_With_Commands_Resumes_Successfully() { - var session1 = await CreateSessionAsync(); + await using var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { diff --git a/dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs b/dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs index ea4ae15b4..092b28971 100644 --- a/dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs +++ b/dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs @@ -29,7 +29,7 @@ public async Task Should_Return_Null_Or_Empty_Content_For_Unknown_Checkpoint() { await using var session = await CreateSessionAsync(); - var result = await session.Rpc.Workspaces.ReadCheckpointAsync(long.MaxValue); + var result = await session.Rpc.Workspaces.ReadCheckpointAsync(uint.MaxValue); Assert.True(string.IsNullOrEmpty(result.Content)); } diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index ad313b116..1bc4c52eb 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -173,9 +173,11 @@ public async Task Should_Apply_All_ReasoningEffort_Values_On_Session_Create(stri [Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)] public async Task Should_Apply_ReasoningEffort_On_Session_Resume() { - var originalSession = await CreateSessionAsync(); + await using var originalSession = await CreateSessionAsync(); + var sessionId = originalSession.SessionId; + await SuspendAndUntrackSessionForResumeAsync(originalSession); const string reasoningModelId = "custom-reasoning-model"; - var resumedSession = await ResumeSessionAsync(originalSession.SessionId, new ResumeSessionConfig + var resumedSession = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { Model = reasoningModelId, Provider = CreateProxyProvider("resume-reasoning"), @@ -187,7 +189,6 @@ public async Task Should_Apply_ReasoningEffort_On_Session_Resume() Assert.Equal("high", resumeEvent.Data.ReasoningEffort); await resumedSession.DisposeAsync(); - await originalSession.DisposeAsync(); } [Fact] @@ -233,8 +234,9 @@ public async Task Should_Forward_Custom_Provider_Headers_On_Create() [Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)] public async Task Should_Forward_Custom_Provider_Headers_On_Resume() { - var session1 = await CreateSessionAsync(); + await using var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { @@ -339,8 +341,9 @@ public async Task Should_Apply_WorkingDirectory_On_Session_Resume() Directory.CreateDirectory(subDir); await File.WriteAllTextAsync(Path.Join(subDir, "resume-marker.txt"), "I am in the resume working directory"); - var session1 = await CreateSessionAsync(); + await using var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { @@ -360,8 +363,9 @@ public async Task Should_Apply_WorkingDirectory_On_Session_Resume() [Fact] public async Task Should_Apply_SystemMessage_On_Session_Resume() { - var session1 = await CreateSessionAsync(); + await using var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); var resumeInstruction = "End the response with RESUME_SYSTEM_MESSAGE_SENTINEL."; var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig @@ -422,11 +426,13 @@ await File.WriteAllTextAsync( Path.Join(instructionFilesDir, "extra.instructions.md"), $"Always include {sentinel}."); - var session1 = await CreateSessionAsync(new SessionConfig + await using var session1 = await CreateSessionAsync(new SessionConfig { WorkingDirectory = projectDir, }); - var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { WorkingDirectory = projectDir, InstructionDirectories = [instructionDir], @@ -438,14 +444,14 @@ await File.WriteAllTextAsync( Assert.Contains(sentinel, GetSystemMessage(exchange)); await session2.DisposeAsync(); - await session1.DisposeAsync(); } [Fact] public async Task Should_Apply_AvailableTools_On_Session_Resume() { - var session1 = await CreateSessionAsync(); + await using var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { @@ -493,8 +499,10 @@ public async Task Should_Apply_Session_Limits_On_Create() [Fact] public async Task Should_Apply_Session_Limits_On_Resume() { - var session1 = await CreateSessionAsync(); - var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + await using var session1 = await CreateSessionAsync(); + var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { SessionLimits = new SessionLimitsConfig { @@ -513,7 +521,6 @@ public async Task Should_Apply_Session_Limits_On_Resume() finally { await session2.DisposeAsync(); - await session1.DisposeAsync(); } } @@ -558,8 +565,10 @@ public async Task Should_Apply_Excluded_Built_In_Agents_On_Resume() { const string excludedAgent = "explore"; - var session1 = await CreateSessionAsync(); - var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + await using var session1 = await CreateSessionAsync(); + var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { ExcludedBuiltInAgents = [excludedAgent], }); @@ -575,7 +584,6 @@ public async Task Should_Apply_Excluded_Built_In_Agents_On_Resume() finally { await session2.DisposeAsync(); - await session1.DisposeAsync(); } } diff --git a/dotnet/test/E2E/SessionE2ETests.cs b/dotnet/test/E2E/SessionE2ETests.cs index e47b6b21b..bc9ee703b 100644 --- a/dotnet/test/E2E/SessionE2ETests.cs +++ b/dotnet/test/E2E/SessionE2ETests.cs @@ -228,7 +228,7 @@ public async Task Should_Create_Session_With_Custom_Tool() [Fact] public async Task Should_Reject_Resuming_Active_Session_Using_The_Same_Client() { - var session1 = await CreateSessionAsync(); + await using var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; var exception = await Assert.ThrowsAsync(() => @@ -983,8 +983,9 @@ public async Task Should_Create_Session_With_Azure_Provider() [Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)] public async Task Should_Resume_Session_With_Custom_Provider() { - var session = await CreateSessionAsync(); + await using var session = await CreateSessionAsync(); var sessionId = session.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session); var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { @@ -1006,7 +1007,5 @@ public async Task Should_Resume_Session_With_Custom_Provider() { // disconnect may fail since the provider is fake } - - await session.DisposeAsync(); } } diff --git a/dotnet/test/E2E/SkillsE2ETests.cs b/dotnet/test/E2E/SkillsE2ETests.cs index 76f84106f..3b005fc01 100644 --- a/dotnet/test/E2E/SkillsE2ETests.cs +++ b/dotnet/test/E2E/SkillsE2ETests.cs @@ -208,13 +208,14 @@ public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories() var skillsDir = CreateSkillDir(); // Create a session without skills first - var session1 = await CreateSessionAsync(); + await using var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; // First message without skill - marker should not appear var message1 = await session1.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." }); Assert.NotNull(message1); Assert.DoesNotContain(SkillMarker, message1!.Data.Content); + await SuspendAndUntrackSessionForResumeAsync(session1); // Resume with skillDirectories - skill should now be active var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig diff --git a/dotnet/test/Harness/E2ETestBase.cs b/dotnet/test/Harness/E2ETestBase.cs index a3006389b..3eb0f0e97 100644 --- a/dotnet/test/Harness/E2ETestBase.cs +++ b/dotnet/test/Harness/E2ETestBase.cs @@ -59,6 +59,7 @@ internal static string GetTestName(ITestOutputHelper output) public async Task InitializeAsync() { + Ctx.PrepareForTest(); await Ctx.CleanupAfterTestAsync(); await Ctx.ConfigureForTestAsync(_snapshotCategory, _testName); } @@ -88,17 +89,40 @@ protected async Task ResumeSessionAsync(string sessionId, Resume config ??= new ResumeSessionConfig(); config.OnPermissionRequest ??= PermissionHandler.ApproveAll; - await Client.StartAsync(); - var port = Client.RuntimePort - ?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume."); - - var client = Ctx.CreateClient(options: new CopilotClientOptions + CopilotClient client; + if (E2ETestContext.UsesInProcessTransport) + { + client = Client; + } + else { - Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken), - }); + await Client.StartAsync(); + var port = Client.RuntimePort + ?? throw new InvalidOperationException("The shared E2E client must use TCP transport to support multi-client resume."); + + client = Ctx.CreateClient(options: new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: E2ETestFixture.SharedTcpConnectionToken), + }); + } + return await Ctx.ResumeSessionAsync(client, sessionId, config); } + protected static async Task SuspendAndUntrackSessionForResumeAsync(CopilotSession session) + { + await session.Rpc.SuspendAsync(); + + // In-process clients host separate runtimes, while session.destroy removes the + // session from the current runtime. Untrack locally to exercise resume without + // either replacing an active wrapper or destroying the session first. + var removeFromClient = typeof(CopilotSession).GetMethod( + "RemoveFromClient", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("CopilotSession.RemoveFromClient was not found."); + removeFromClient.Invoke(session, null); + } + protected static string GetSystemMessage(ParsedHttpExchange exchange) { return exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.StringContent ?? string.Empty; diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 809ab55a3..af34de6ec 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -17,6 +17,7 @@ public sealed class E2ETestContext : IAsyncDisposable public string HomeDir { get; } public string WorkDir { get; } public string ProxyUrl { get; } + internal static bool UsesInProcessTransport => IsInProcess(null); /// Optional logger injected by tests; applied to all clients created via . public ILogger? Logger { get; set; } @@ -322,22 +323,8 @@ public CopilotClient CreateClient( if (IsInProcess(options.Connection)) { - // In-process hosting: runtime code runs host-side in this process (the - // loaded cdylib) and reads the ambient process environment rather than - // the environment passed to copilot_runtime_host_start, so the per-test - // redirects, cleared tokens/HMAC, and isolated home must be mirrored - // onto this process's real environment. Restored after each test by - // InProcessEnvIsolationAttribute. - foreach (var (name, value) in env) - { - InProcessEnvIsolation.Apply(name, value); - } - - // A per-client WorkingDirectory is rejected in-process; instead point this - // process's cwd at the desired directory so the worker inherits it at spawn - // (restored after the test by InProcessEnvIsolationAttribute). options.WorkingDirectory = null; - InProcessEnvIsolation.SetWorkingDirectory(desiredWorkingDirectory); + ApplyInProcessEnvironment(env, desiredWorkingDirectory); } else if (options.Connection is ChildProcessRuntimeConnection child) { @@ -395,6 +382,29 @@ public Task ResumeSessionAsync( return client.ResumeSessionAsync(sessionId, config); } + internal void PrepareForTest() + { + if (UsesInProcessTransport) + { + ApplyInProcessEnvironment(GetEnvironment(), WorkDir); + } + } + + private static void ApplyInProcessEnvironment(IReadOnlyDictionary environment, string workingDirectory) + { + // Runtime code runs host-side in this process and reads its ambient environment, + // so restore the per-test redirects and isolated home after the assembly-level + // isolation attribute reset them at the end of the preceding test. + foreach (var (name, value) in environment) + { + InProcessEnvIsolation.Apply(name, value); + } + + // The worker inherits the host process cwd because the native host has no + // per-client working-directory parameter. + InProcessEnvIsolation.SetWorkingDirectory(workingDirectory); + } + public void UntrackClient(CopilotClient client) { lock (_clientsLock) diff --git a/dotnet/test/Harness/E2ETestFixture.cs b/dotnet/test/Harness/E2ETestFixture.cs index 95bebc139..e29f5f7f6 100644 --- a/dotnet/test/Harness/E2ETestFixture.cs +++ b/dotnet/test/Harness/E2ETestFixture.cs @@ -19,10 +19,15 @@ public async Task InitializeAsync() Ctx = await E2ETestContext.CreateAsync(); Client = Ctx.CreateClient(options: new CopilotClientOptions { - Connection = RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken), + Connection = CreateSharedConnection(E2ETestContext.UsesInProcessTransport), }, persistent: true); } + internal static RuntimeConnection CreateSharedConnection(bool useInProcessTransport) => + useInProcessTransport + ? RuntimeConnection.ForInProcess() + : RuntimeConnection.ForTcp(connectionToken: SharedTcpConnectionToken); + public async Task DisposeAsync() { await Ctx.DisposeAsync(); diff --git a/dotnet/test/Unit/E2ETestFixtureTests.cs b/dotnet/test/Unit/E2ETestFixtureTests.cs new file mode 100644 index 000000000..f7dee3ce0 --- /dev/null +++ b/dotnet/test/Unit/E2ETestFixtureTests.cs @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public class E2ETestFixtureTests +{ + [Fact] + public void Shared_Client_Uses_InProcess_Connection_For_InProcess_Tests() + { + var connection = E2ETestFixture.CreateSharedConnection(useInProcessTransport: true); + + Assert.IsType(connection); + } + + [Fact] + public void Shared_Client_Preserves_Tcp_Connection_For_OutOfProcess_Tests() + { + var connection = Assert.IsType( + E2ETestFixture.CreateSharedConnection(useInProcessTransport: false)); + + Assert.Equal(E2ETestFixture.SharedTcpConnectionToken, connection.ConnectionToken); + } +}