Skip to content

Commit 8d191fd

Browse files
Simplify in-process env isolation to snapshot/restore
Replace the targeted per-name pristine tracking (s_pristineByName, Mirror/Restore) with a blanket load-time environment snapshot restored after every test. The attribute now blanks leaky CI credentials before each test and reverts the whole process environment afterwards, regardless of transport, so per-test mirrors and fixture-teardown backstops in E2ETestContext are no longer needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f250a9e commit 8d191fd

4 files changed

Lines changed: 74 additions & 184 deletions

File tree

dotnet/test/AssemblyInfo.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,4 @@
1515
// semaphore that limits concurrent fixtures to a small number (e.g. 2-3).
1616
[assembly: CollectionBehavior(DisableTestParallelization = true)]
1717

18-
// TEMPORARY: isolate the ambient process environment around every test in the
19-
// in-process job so directly-constructed clients cannot pick up ambient CI
20-
// credentials and reach the live API. No-op outside the in-process job. Delete
21-
// together with InProcessEnvIsolation.cs once the runtime stops reading the
22-
// ambient process environment host-side.
2318
[assembly: InProcessEnvIsolation]

dotnet/test/Harness/E2ETestContext.cs

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -287,14 +287,11 @@ public CopilotClient CreateClient(
287287
// copilot_runtime_host_start, so our per-test redirects, cleared tokens,
288288
// cleared HMAC keys, and isolated home in options.Environment are
289289
// invisible to them unless mirrored onto this process's real environment.
290-
// All of this hackery lives in InProcessEnvIsolation so it can be deleted
291-
// in one place once the runtime stops reading the ambient process env.
292-
if (InProcessEnvIsolation.IsActive(options.Environment))
290+
// Restored after each test by InProcessEnvIsolationAttribute. Harmless for
291+
// child-process transports, which configure their child's environment.
292+
foreach (var (name, value) in options.Environment)
293293
{
294-
foreach (var (name, value) in options.Environment)
295-
{
296-
InProcessEnvIsolation.Mirror(name, value);
297-
}
294+
InProcessEnvIsolation.Apply(name, value);
298295
}
299296

300297
// Auto-inject auth token unless connecting to an existing runtime via URI.
@@ -343,27 +340,16 @@ public async Task CleanupAfterTestAsync()
343340
_transientClients.Clear();
344341
}
345342

346-
try
343+
foreach (var client in transientClients)
347344
{
348-
foreach (var client in transientClients)
345+
try
349346
{
350-
try
351-
{
352-
await StopClientForCleanupAsync(client);
353-
}
354-
catch (Exception ex) when (IsTransientCleanupException(ex))
355-
{
356-
errors.Add(ex);
357-
}
347+
await StopClientForCleanupAsync(client);
348+
}
349+
catch (Exception ex) when (IsTransientCleanupException(ex))
350+
{
351+
errors.Add(ex);
358352
}
359-
}
360-
finally
361-
{
362-
// Undo any in-process env mirroring so it cannot leak into the next
363-
// test. In a finally so a non-transient force-stop failure above can
364-
// never skip it (a skipped restore would otherwise strand the shared
365-
// process env in its cleared/redirected state until the next mirror).
366-
InProcessEnvIsolation.Restore();
367353
}
368354

369355
if (errors.Count == 1)
@@ -400,11 +386,6 @@ public async ValueTask DisposeAsync()
400386
}
401387
}
402388

403-
// Backstop: revert any in-process env mirroring at fixture teardown too,
404-
// so a class's mutations cannot survive into the next class even if a
405-
// per-test cleanup was bypassed.
406-
InProcessEnvIsolation.Restore();
407-
408389
// Skip writing snapshots in CI to avoid corrupting them on test failures
409390
var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"));
410391
try { await _proxy.StopAsync(skipWritingCache: isCI); } catch (Exception ex) when (IsTransientCleanupException(ex)) { errors.Add(ex); }
@@ -462,7 +443,11 @@ private static async Task DeleteDirectoryAsync(string path)
462443
// Inproc holds the session-store SQLite handle in-process; graceful StopAsync releases it so the temp-dir delete succeeds on Windows.
463444
private static async Task StopClientForCleanupAsync(CopilotClient client)
464445
{
465-
if (InProcessEnvIsolation.IsActive())
446+
var isInProcess = string.Equals(
447+
Environment.GetEnvironmentVariable("COPILOT_SDK_DEFAULT_CONNECTION"),
448+
"inprocess",
449+
StringComparison.OrdinalIgnoreCase);
450+
if (isInProcess)
466451
{
467452
await client.StopAsync();
468453
}

dotnet/test/Harness/InProcessEnvIsolation.cs

Lines changed: 45 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -2,68 +2,32 @@
22
* Copyright (c) Microsoft Corporation. All rights reserved.
33
*--------------------------------------------------------------------------------------------*/
44

5+
using System.Collections;
56
using System.Reflection;
7+
using System.Runtime.CompilerServices;
68
using System.Runtime.InteropServices;
79
using Xunit.Sdk;
810

911
namespace GitHub.Copilot.Test.Harness;
1012

11-
// =============================================================================
12-
// TEMPORARY in-process env-var isolation. DELETE THIS ENTIRE FILE (and its two
13-
// references in E2ETestContext plus the [assembly: InProcessEnvIsolation] in
14-
// AssemblyInfo.cs) once the runtime stops reading the ambient process
15-
// environment host-side.
16-
//
17-
// Why this exists
18-
// ---------------
19-
// Over the in-process FFI transport several runtime code paths run host-side in
20-
// THIS process (the loaded cdylib) and read the ambient process environment
21-
// rather than the environment passed to copilot_runtime_host_start — e.g.
22-
// native fetch_copilot_user reads COPILOT_DEBUG_GITHUB_API_URL via
23-
// std::env::var, the gh-CLI fallback spawns `gh auth token` (inheriting this
24-
// process's GH_TOKEN / GITHUB_TOKEN / GH_CONFIG_DIR), auth-method selection
25-
// reads the HMAC keys, and session state/config reads COPILOT_HOME / XDG_*.
26-
// So the per-test environment we hand to CopilotClient is invisible to them.
27-
//
28-
// Two problems follow, both handled here:
29-
// 1. CreateClient-routed tests must mirror their per-test environment onto this
30-
// process so host-side reads observe the replay proxy, isolated home, and
31-
// cleared credentials. See Mirror/Restore below.
32-
// 2. Tests that construct CopilotClient directly (e.g. ClientE2ETests) never go
33-
// through CreateClient, so nothing clears the ambient CI HMAC credential;
34-
// in the in-process job they would authenticate for real and hit the live
35-
// api.githubcopilot.com. The assembly-level BeforeAfterTest attribute below
36-
// neutralizes those ambient credentials around EVERY test, independent of
37-
// how the client is constructed.
38-
//
39-
// Everything here is gated to the in-process job (COPILOT_SDK_DEFAULT_CONNECTION
40-
// == "inprocess") and is a no-op otherwise.
41-
// =============================================================================
42-
43-
/// <summary>
44-
/// Owns all process-wide environment mutation used to make the in-process FFI
45-
/// transport hermetic in tests. Consolidated in one file so it can be deleted
46-
/// wholesale once the runtime no longer reads the ambient process environment.
47-
/// </summary>
13+
// Because many of the tests mutate global environment variables, we have to snapshot the original
14+
// state and restore it after each test. Otherwise tests influence each other depending on run order.
15+
// This is especially important for the in-process transport because the runtime is inside the test
16+
// host process and will be reading/writing its environment variables directly.
4817
internal static class InProcessEnvIsolation
4918
{
50-
// Ambient credentials that would otherwise let a directly-constructed client
51-
// authenticate for real (and reach the live API) in the in-process job. CI
52-
// sets COPILOT_HMAC_KEY at the job level; the replay snapshots are captured
53-
// against Bearer/OAuth requests, so real HMAC auth must be disabled. An empty
54-
// value disables the method (the runtime filters out empty HMAC keys).
55-
private static readonly string[] LeakyCredentialVars = ["COPILOT_HMAC_KEY", "CAPI_HMAC_KEY"];
19+
// Unset because CI sets them but the replay snapshots expect Bearer/OAuth.
20+
private static readonly string[] SuppressEnvVars = ["COPILOT_HMAC_KEY", "CAPI_HMAC_KEY"];
5621

57-
private static readonly object s_lock = new();
22+
// Captured at load, before any fixture/test mutates env.
23+
private static readonly Dictionary<string, string?> s_ambient = CaptureEnvironment();
5824

59-
// Process-wide, permanent record of the PRISTINE (pre-any-mirror) value of
60-
// every variable we have ever overwritten. A null entry means the variable
61-
// was originally unset. Captured once per name and never overwritten, so it
62-
// is immune to a cascade in which a skipped restore would otherwise let a
63-
// later test back up an already-polluted value as its baseline. Static
64-
// because the shared process env is itself process-global and E2E tests run
65-
// serially (DisableTestParallelization).
66-
private static readonly Dictionary<string, string?> s_pristineByName = new();
25+
// Runs at assembly load so the ambient env is snapshotted before the shared
26+
// fixture mirrors per-test env onto the process. Justifies suppressing CA2255.
27+
#pragma warning disable CA2255 // ModuleInitializer discouraged in libraries; intentional in this test harness.
28+
[ModuleInitializer]
29+
internal static void CaptureAtLoad() => _ = s_ambient;
30+
#pragma warning restore CA2255
6731

6832
[DllImport("libc", EntryPoint = "setenv", CharSet = CharSet.Ansi,
6933
BestFitMapping = false, ThrowOnUnmappableChar = true)]
@@ -73,124 +37,57 @@ internal static class InProcessEnvIsolation
7337
BestFitMapping = false, ThrowOnUnmappableChar = true)]
7438
private static extern int NativeUnsetEnv(string name);
7539

76-
/// <summary>
77-
/// Whether the in-process FFI transport is the default for this run, honoring
78-
/// COPILOT_SDK_DEFAULT_CONNECTION from the supplied per-test environment (if
79-
/// any) else the process environment. Mirrors CopilotClient's own resolution.
80-
/// </summary>
81-
public static bool IsActive(IReadOnlyDictionary<string, string>? environment = null)
82-
{
83-
var value = environment is not null
84-
&& environment.TryGetValue("COPILOT_SDK_DEFAULT_CONNECTION", out var fromOptions)
85-
? fromOptions
86-
: Environment.GetEnvironmentVariable("COPILOT_SDK_DEFAULT_CONNECTION");
87-
return string.Equals(value, "inprocess", StringComparison.OrdinalIgnoreCase);
88-
}
89-
90-
/// <summary>
91-
/// Mirrors a variable onto the shared process environment for the in-process
92-
/// host-side runtime to observe, recording the pristine value once so
93-
/// <see cref="Restore"/> can always revert to the true original.
94-
/// </summary>
95-
public static void Mirror(string name, string value)
40+
// Sets/unsets on the managed cache and, on Unix, the libc block so native
41+
// readers in the loaded cdylib observe it.
42+
public static void Apply(string name, string? value)
9643
{
97-
lock (s_lock)
98-
{
99-
if (!s_pristineByName.ContainsKey(name))
100-
{
101-
s_pristineByName[name] = Environment.GetEnvironmentVariable(name);
102-
}
103-
}
104-
105-
SetProcessEnvironmentVariable(name, value);
106-
}
107-
108-
/// <summary>
109-
/// Reverts every variable ever touched by <see cref="Mirror"/> back to its
110-
/// permanently-recorded pristine value (or unsets it). Idempotent and
111-
/// cascade-proof: because pristine values are never overwritten, calling this
112-
/// always restores the true ambient environment even if a previous restore
113-
/// was skipped.
114-
/// </summary>
115-
public static void Restore()
116-
{
117-
KeyValuePair<string, string?>[] pristine;
118-
lock (s_lock)
119-
{
120-
if (s_pristineByName.Count == 0)
121-
{
122-
return;
123-
}
124-
125-
pristine = [.. s_pristineByName];
126-
}
127-
128-
foreach (var (name, value) in pristine)
44+
Environment.SetEnvironmentVariable(name, value);
45+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
12946
{
130-
RestoreProcessEnvironmentVariable(name, value);
47+
_ = value is null ? NativeUnsetEnv(name) : NativeSetEnv(name, value, 1);
13148
}
13249
}
13350

134-
/// <summary>
135-
/// Neutralizes ambient credentials that would otherwise let a directly
136-
/// constructed client authenticate for real in the in-process job. Recorded
137-
/// via <see cref="Mirror"/> so <see cref="Restore"/> reverts them.
138-
/// </summary>
13951
public static void NeutralizeAmbientCredentials()
14052
{
141-
foreach (var name in LeakyCredentialVars)
53+
foreach (var name in SuppressEnvVars)
14254
{
143-
Mirror(name, "");
55+
Apply(name, null);
14456
}
14557
}
14658

147-
// Sets an environment variable on both the managed cache and (on Unix) the
148-
// libc environment block, so native getenv/std::env::var readers in the
149-
// loaded cdylib observe it. On Windows the managed setter already reaches
150-
// native GetEnvironmentVariableW, so setenv is not needed.
151-
private static void SetProcessEnvironmentVariable(string name, string value)
59+
public static void RestoreAmbient()
15260
{
153-
Environment.SetEnvironmentVariable(name, value);
154-
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
61+
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
15562
{
156-
_ = NativeSetEnv(name, value, 1);
63+
var name = (string)entry.Key;
64+
if (!s_ambient.ContainsKey(name))
65+
{
66+
Apply(name, null);
67+
}
15768
}
158-
}
15969

160-
// Restores (or unsets) an environment variable on both the managed cache and
161-
// (on Unix) the libc environment block.
162-
private static void RestoreProcessEnvironmentVariable(string name, string? value)
163-
{
164-
Environment.SetEnvironmentVariable(name, value);
165-
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
70+
foreach (var (name, value) in s_ambient)
16671
{
167-
_ = value is null ? NativeUnsetEnv(name) : NativeSetEnv(name, value, 1);
72+
if (!string.Equals(Environment.GetEnvironmentVariable(name), value, StringComparison.Ordinal))
73+
{
74+
Apply(name, value);
75+
}
16876
}
16977
}
78+
79+
private static Dictionary<string, string?> CaptureEnvironment() =>
80+
Environment.GetEnvironmentVariables()
81+
.Cast<DictionaryEntry>()
82+
.ToDictionary(e => (string)e.Key, e => e.Value?.ToString(), StringComparer.Ordinal);
17083
}
17184

172-
/// <summary>
173-
/// Assembly-level xUnit hook that isolates the ambient process environment around
174-
/// every test in the in-process job, independent of how the CopilotClient is
175-
/// constructed. No-op outside the in-process job. TEMPORARY — see
176-
/// <see cref="InProcessEnvIsolation"/>.
177-
/// </summary>
17885
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
17986
public sealed class InProcessEnvIsolationAttribute : BeforeAfterTestAttribute
18087
{
181-
public override void Before(MethodInfo methodUnderTest)
182-
{
183-
if (InProcessEnvIsolation.IsActive())
184-
{
185-
InProcessEnvIsolation.NeutralizeAmbientCredentials();
186-
}
187-
}
88+
public override void Before(MethodInfo methodUnderTest) =>
89+
InProcessEnvIsolation.NeutralizeAmbientCredentials();
18890

189-
public override void After(MethodInfo methodUnderTest)
190-
{
191-
if (InProcessEnvIsolation.IsActive())
192-
{
193-
InProcessEnvIsolation.Restore();
194-
}
195-
}
91+
public override void After(MethodInfo methodUnderTest) =>
92+
InProcessEnvIsolation.RestoreAmbient();
19693
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
#if !NET5_0_OR_GREATER
6+
namespace System.Runtime.CompilerServices;
7+
8+
// Polyfill so [ModuleInitializer] compiles on net472; recognized by the compiler.
9+
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
10+
internal sealed class ModuleInitializerAttribute : Attribute
11+
{
12+
}
13+
#endif

0 commit comments

Comments
 (0)