Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/dotnet-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
transport: ["default", "inprocess"]
# TODO: Re-enable after fixing in-process sqlite file locking on shutdown on Windows
exclude:
- os: windows-latest
transport: "inprocess"
runs-on: ${{ matrix.os }}
defaults:
run:
Expand Down
39 changes: 28 additions & 11 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,14 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
{
// In-process FFI hosting: load the Rust cdylib and let it spawn
// the CLI worker, instead of the SDK launching a CLI child process.
var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), _options.Environment, _logger);
// The worker reads its configuration (telemetry export, etc.) from
// the environment passed here, so apply the same telemetry-derived
// vars the child-process path sets on its startInfo.Environment.
var ffiEnvironment = _options.Environment?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)
?? new Dictionary<string, string?>();
ApplyTelemetryEnvironment(ffiEnvironment, _options.Telemetry);
var resolvedFfiEnvironment = ffiEnvironment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value!);
var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), resolvedFfiEnvironment, _logger);
_ffiHost = ffiHost;
await ffiHost.StartAsync(ct);
connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost);
Expand Down Expand Up @@ -1909,6 +1916,25 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex)
|| string.Equals(ex.Message, "Unhandled method connect", StringComparison.Ordinal);
}

// Applies the telemetry-derived environment variables the runtime reads to
// enable OTLP export. Shared by the stdio/tcp child-process path and the
// in-process FFI path so telemetry behaves identically across transports.
private static void ApplyTelemetryEnvironment(IDictionary<string, string?> environment, TelemetryConfig? telemetry)
{
if (telemetry is null)
{
return;
}

environment["COPILOT_OTEL_ENABLED"] = "true";
if (telemetry.OtlpEndpoint is not null) environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = telemetry.OtlpEndpoint;
if (telemetry.OtlpProtocol is not null) environment["OTEL_EXPORTER_OTLP_PROTOCOL"] = telemetry.OtlpProtocol;
if (telemetry.FilePath is not null) environment["COPILOT_OTEL_FILE_EXPORTER_PATH"] = telemetry.FilePath;
if (telemetry.ExporterType is not null) environment["COPILOT_OTEL_EXPORTER_TYPE"] = telemetry.ExporterType;
if (telemetry.SourceName is not null) environment["COPILOT_OTEL_SOURCE_NAME"] = telemetry.SourceName;
if (telemetry.CaptureContent is { } capture) environment["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = capture ? "true" : "false";
}

private async Task<(Process Process, int? DetectedLocalhostTcpPort, ProcessStderrPump StderrPump)> StartCliServerAsync(CancellationToken cancellationToken)
{
var options = _options;
Expand Down Expand Up @@ -2023,16 +2049,7 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex)
}

// Set telemetry environment variables if configured
if (options.Telemetry is { } telemetry)
{
startInfo.Environment["COPILOT_OTEL_ENABLED"] = "true";
if (telemetry.OtlpEndpoint is not null) startInfo.Environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = telemetry.OtlpEndpoint;
if (telemetry.OtlpProtocol is not null) startInfo.Environment["OTEL_EXPORTER_OTLP_PROTOCOL"] = telemetry.OtlpProtocol;
if (telemetry.FilePath is not null) startInfo.Environment["COPILOT_OTEL_FILE_EXPORTER_PATH"] = telemetry.FilePath;
if (telemetry.ExporterType is not null) startInfo.Environment["COPILOT_OTEL_EXPORTER_TYPE"] = telemetry.ExporterType;
if (telemetry.SourceName is not null) startInfo.Environment["COPILOT_OTEL_SOURCE_NAME"] = telemetry.SourceName;
if (telemetry.CaptureContent is { } capture) startInfo.Environment["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = capture ? "true" : "false";
}
ApplyTelemetryEnvironment(startInfo.Environment, options.Telemetry);

var cliProcess = new Process { StartInfo = startInfo };
try
Expand Down
2 changes: 2 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ public static UriRuntimeConnection ForUri(string url, string? connectionToken =
/// Works across the SDK's target frameworks: modern .NET uses <c>NativeLibrary</c>,
/// while <c>netstandard2.0</c> consumers use a built-in fallback native loader.
/// </remarks>
[Experimental(Diagnostics.Experimental)]
public static InProcessRuntimeConnection ForInProcess()
=> new();
}
Expand Down Expand Up @@ -190,6 +191,7 @@ internal StdioRuntimeConnection() { }
/// To point at a non-default runtime entrypoint, set the <c>COPILOT_CLI_PATH</c>
/// environment variable.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public sealed class InProcessRuntimeConnection : RuntimeConnection
{
internal InProcessRuntimeConnection() { }
Expand Down
8 changes: 8 additions & 0 deletions dotnet/test/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*--------------------------------------------------------------------------------------------*/

using Xunit;
using GitHub.Copilot.Test.Harness;

// Each E2E test class fixture spins up its own Copilot CLI subprocess plus a CapiProxy
// (replaying HTTP proxy) Node.js subprocess. With ~25 test classes, running them in parallel
Expand All @@ -13,3 +14,10 @@
// (a) sharing a single CLI subprocess across classes, or (b) gating concurrency with a
// semaphore that limits concurrent fixtures to a small number (e.g. 2-3).
[assembly: CollectionBehavior(DisableTestParallelization = true)]

// TEMPORARY: isolate the ambient process environment around every test in the
// in-process job so directly-constructed clients cannot pick up ambient CI
// credentials and reach the live API. No-op outside the in-process job. Delete
// together with InProcessEnvIsolation.cs once the runtime stops reading the
// ambient process environment host-side.
[assembly: InProcessEnvIsolation]
92 changes: 78 additions & 14 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ public Dictionary<string, string> GetEnvironment()

env["GITHUB_TOKEN"] = env["GH_TOKEN"] = DefaultGitHubToken;

// Disable HMAC auth for E2E runs. CI sets COPILOT_HMAC_KEY at the job
// level as an ambient credential, but the replay snapshots are captured
// against Bearer/OAuth (SDK-token) requests. In stdio the SDK token
// outranks HMAC so this is a no-op, but in-process auth resolution runs
// host-side in this process and would otherwise pick HMAC (which ranks
// above the GitHub token) and fail provider.getEndpoint. An empty value
// disables the method (runtime filters out empty HMAC keys).
env["COPILOT_HMAC_KEY"] = "";
env["CAPI_HMAC_KEY"] = "";

return env!;
}

Expand All @@ -238,9 +248,10 @@ public CopilotClient CreateClient(
options.Environment ??= GetEnvironment();
options.Logger ??= Logger;

// Build the connection. If the caller supplied one, just ensure the runtime path is set;
// otherwise default to Stdio with the bundled runtime (matches CopilotClient's own default).
// useStdio is a convenience shortcut for the no-Connection case; passing both is ambiguous.
// Build the connection. If the caller supplied one, just ensure the runtime path is set.
// When neither a Connection nor useStdio is specified, leave Connection null so
// CopilotClient honors COPILOT_SDK_DEFAULT_CONNECTION (defaulting to stdio); useStdio
// is a convenience shortcut to pin stdio/tcp. Passing both a Connection and useStdio is ambiguous.
if (useStdio is not null && options.Connection is not null)
{
throw new ArgumentException(
Expand All @@ -252,16 +263,40 @@ public CopilotClient CreateClient(
var cliPath = GetCliPath(_repoRoot);
switch (options.Connection)
{
case null when useStdio == true:
options.Connection = RuntimeConnection.ForStdio(path: cliPath);
break;
case null when useStdio == false:
options.Connection = RuntimeConnection.ForTcp(path: cliPath);
break;
case null:
options.Connection = useStdio == false
? RuntimeConnection.ForTcp(path: cliPath)
: RuntimeConnection.ForStdio(path: cliPath);
// useStdio is null: leave Connection unset so CopilotClient's
// ResolveDefaultConnection honors COPILOT_SDK_DEFAULT_CONNECTION
// (stdio by default, or in-process). The CLI path flows through
// options.Environment["COPILOT_CLI_PATH"] (GetEnvironment copies
// the process env, where CI's setup-copilot sets it).
break;
case ChildProcessRuntimeConnection child when child.Path is null:
child.Path = cliPath;
break;
}

// In-process hosting workaround: several runtime code paths run host-side
// in this process (the loaded cdylib) and read the ambient process
// environment rather than the environment passed to
// copilot_runtime_host_start, so our per-test redirects, cleared tokens,
// cleared HMAC keys, and isolated home in options.Environment are
// invisible to them unless mirrored onto this process's real environment.
// All of this hackery lives in InProcessEnvIsolation so it can be deleted
// in one place once the runtime stops reading the ambient process env.
if (InProcessEnvIsolation.IsActive(options.Environment))
{
foreach (var (name, value) in options.Environment)
{
InProcessEnvIsolation.Mirror(name, value);
}
}
Comment on lines +292 to +298

// Auto-inject auth token unless connecting to an existing runtime via URI.
var isExistingRuntime = options.Connection is UriRuntimeConnection;
if (autoInjectGitHubToken
Expand Down Expand Up @@ -308,17 +343,28 @@ public async Task CleanupAfterTestAsync()
_transientClients.Clear();
}

foreach (var client in transientClients)
try
{
try
foreach (var client in transientClients)
{
await client.ForceStopAsync();
}
catch (Exception ex) when (IsTransientCleanupException(ex))
{
errors.Add(ex);
try
{
await StopClientForCleanupAsync(client);
}
catch (Exception ex) when (IsTransientCleanupException(ex))
{
errors.Add(ex);
}
}
}
finally
{
// Undo any in-process env mirroring so it cannot leak into the next
// test. In a finally so a non-transient force-stop failure above can
// never skip it (a skipped restore would otherwise strand the shared
// process env in its cleared/redirected state until the next mirror).
InProcessEnvIsolation.Restore();
}

if (errors.Count == 1)
{
Expand Down Expand Up @@ -346,14 +392,19 @@ public async ValueTask DisposeAsync()
{
try
{
await client.ForceStopAsync();
await StopClientForCleanupAsync(client);
}
catch (Exception ex) when (IsTransientCleanupException(ex))
{
errors.Add(ex);
}
}

// Backstop: revert any in-process env mirroring at fixture teardown too,
// so a class's mutations cannot survive into the next class even if a
// per-test cleanup was bypassed.
InProcessEnvIsolation.Restore();

// Skip writing snapshots in CI to avoid corrupting them on test failures
var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"));
try { await _proxy.StopAsync(skipWritingCache: isCI); } catch (Exception ex) when (IsTransientCleanupException(ex)) { errors.Add(ex); }
Expand Down Expand Up @@ -408,6 +459,19 @@ private static async Task DeleteDirectoryAsync(string path)
}
}

// Inproc holds the session-store SQLite handle in-process; graceful StopAsync releases it so the temp-dir delete succeeds on Windows.
private static async Task StopClientForCleanupAsync(CopilotClient client)
{
if (InProcessEnvIsolation.IsActive())
{
await client.StopAsync();
}
else
{
await client.ForceStopAsync();
}
}

private static bool IsTransientCleanupException(Exception exception)
=> exception is IOException or UnauthorizedAccessException;
}
Loading
Loading