Skip to content
Draft
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
136 changes: 123 additions & 13 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
private readonly List<LifecycleSubscription> _lifecycleHandlers = [];

private Task<Connection>? _connectionTask;
private FfiRuntimeHost? _ffiHost;
private bool _disposed;
private int? _actualPort;
private int? _negotiatedProtocolVersion;
Expand Down Expand Up @@ -135,13 +136,16 @@
public CopilotClient(CopilotClientOptions? options = null)
{
_options = options ?? new();
_connection = _options.Connection ?? RuntimeConnection.ForStdio();
_connection = _options.Connection ?? ResolveDefaultConnection(_options);

switch (_connection)
{
case StdioRuntimeConnection:
break;

case InProcessRuntimeConnection:
break;

case TcpRuntimeConnection tcp:
if (tcp.ConnectionToken is { Length: 0 })
{
Expand Down Expand Up @@ -198,6 +202,38 @@
}
}

/// <summary>
/// Environment variable that overrides the transport used when the caller does not
/// specify <see cref="CopilotClientOptions.Connection"/>. Accepts <c>"inprocess"</c>
/// or <c>"stdio"</c> (case-insensitive); unset preserves the default stdio transport.
/// Any other value is an error. Ignored when a <see cref="RuntimeConnection"/> is set
/// explicitly.
/// </summary>
internal const string DefaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION";

/// <summary>
/// Resolves the default <see cref="RuntimeConnection"/> for the no-Connection case,
/// honoring <see cref="DefaultConnectionEnvVar"/>.
/// </summary>
private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions options)
{
var value = options.Environment is not null
&& options.Environment.TryGetValue(DefaultConnectionEnvVar, out var fromOptions)
? fromOptions
: Environment.GetEnvironmentVariable(DefaultConnectionEnvVar);

if (string.IsNullOrEmpty(value) || string.Equals(value, "stdio", StringComparison.OrdinalIgnoreCase))
{
return RuntimeConnection.ForStdio();
}
if (string.Equals(value, "inprocess", StringComparison.OrdinalIgnoreCase))
{
return RuntimeConnection.ForInProcess();
}
throw new ArgumentException(
$"Invalid {DefaultConnectionEnvVar} value '{value}'. Expected 'inprocess', 'stdio', or unset.");
}

/// <summary>
/// Parses a runtime URL into a URI with host and port.
/// </summary>
Expand Down Expand Up @@ -251,7 +287,16 @@

try
{
if (_connection is UriRuntimeConnection)
if (_connection is InProcessRuntimeConnection)
{
// 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);
_ffiHost = ffiHost;
await ffiHost.StartAsync(ct);
connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost);
}
else if (_connection is UriRuntimeConnection)
{
// External runtime
_actualPort = _optionsPort;
Expand Down Expand Up @@ -438,7 +483,7 @@

private async Task CleanupConnectionAsync(Connection ctx, List<Exception>? errors, bool gracefulRuntimeShutdown)
{
if (gracefulRuntimeShutdown && ctx.CliProcess is not null)
if (gracefulRuntimeShutdown && (ctx.CliProcess is not null || ctx.FfiHost is not null))
{
var runtimeShutdownTimestamp = Stopwatch.GetTimestamp();
try
Expand Down Expand Up @@ -478,6 +523,13 @@
{
await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger);
}

if (ctx.FfiHost is { } ffiHost)
{
try { ffiHost.Dispose(); }
catch (Exception ex) { AddCleanupError(errors, ex, _logger); }

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
_ffiHost = null;
}
}

private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List<Exception>? errors, ILogger? logger)
Expand Down Expand Up @@ -1795,12 +1847,14 @@
int? serverVersion;
try
{
var token = _connection switch
{
TcpRuntimeConnection tcp => tcp.ConnectionToken,
UriRuntimeConnection uri => uri.ConnectionToken,
_ => null,
};
var token = _ffiHost is not null
? null // FFI hosting is an ungated in-process connection; no token.
: _connection switch
{
TcpRuntimeConnection tcp => tcp.ConnectionToken,
UriRuntimeConnection uri => uri.ConnectionToken,
_ => null,
};
var connectResponse = await InvokeRpcAsync<ConnectResult>(
connection.Rpc, "connect", [new ConnectRequest { Token = token }], connection.StderrBuffer, cancellationToken);
serverVersion = (int)connectResponse.ProtocolVersion;
Expand Down Expand Up @@ -2077,6 +2131,55 @@
return arch != null ? $"{os}-{arch}" : null;
}

private string ResolveCliPathForFfi()
{
var envCliPath = _options.Environment is not null && _options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue)
? envValue
: System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
if (!string.IsNullOrEmpty(envCliPath))
{
return envCliPath;
}

// Fall back to the bundled single-file CLI the same way stdio discovers it.
// It embeds its own Node and is spawned directly as `copilot --embedded-host`,
// with the sibling `prebuilds/<rid>/runtime.node` cdylib loaded in-process.
var bundled = GetBundledCliPath(out var searchedPath);
return bundled
?? throw new InvalidOperationException(
"In-process FFI hosting requires the Copilot CLI. Set the COPILOT_CLI_PATH "
+ $"environment variable, or ensure the bundled CLI is present (looked in '{searchedPath}').");
}

/// <summary>
/// Returns the napi-rs prebuilds folder name for the current host — the
/// <c>&lt;node-platform&gt;-&lt;arch&gt;</c> convention (e.g. <c>win32-x64</c>,
/// <c>darwin-arm64</c>, <c>linux-x64</c>) under which the runtime ships
/// <c>prebuilds/&lt;folder&gt;/runtime.node</c>. This differs from the .NET RID
/// (<c>win-x64</c>/<c>osx-x64</c>) for Windows and macOS.
/// </summary>
private static string? GetNapiPrebuildsFolder()
{
string platform;
if (OperatingSystem.IsWindows()) platform = "win32";
else if (OperatingSystem.IsLinux()) platform = "linux";
else if (OperatingSystem.IsMacOS()) platform = "darwin";
else return null;

var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
{
System.Runtime.InteropServices.Architecture.X64 => "x64",
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
_ => null,
};

return arch != null ? $"{platform}-{arch}" : null;
}

private static string GetNapiPrebuildsFolderOrThrow() =>
GetNapiPrebuildsFolder()
?? throw new InvalidOperationException("Could not determine a napi-rs prebuilds folder for FFI hosting.");

private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(string cliPath, IEnumerable<string> args)
{
var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase);
Expand All @@ -2089,7 +2192,7 @@
return (cliPath, args);
}

private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken)
private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken, FfiRuntimeHost? ffiHost = null)
{
var setupTimestamp = Stopwatch.GetTimestamp();
NetworkStream? networkStream = null;
Expand All @@ -2099,7 +2202,12 @@
{
Stream inputStream, outputStream;

if (_connection is StdioRuntimeConnection)
if (ffiHost is not null)
{
inputStream = ffiHost.ReceiveStream;
outputStream = ffiHost.SendStream;
}
else if (_connection is StdioRuntimeConnection)
{
if (cliProcess == null)
{
Expand Down Expand Up @@ -2165,7 +2273,7 @@
"CopilotClient.ConnectToServerAsync transport setup complete. Elapsed={Elapsed}",
setupTimestamp);

var connection = new Connection(rpc, cliProcess, networkStream, stderrPump);
var connection = new Connection(rpc, cliProcess, networkStream, stderrPump, ffiHost);
_serverRpc = connection.Server;

return connection;
Expand Down Expand Up @@ -2368,14 +2476,16 @@
JsonRpc rpc,
Process? cliProcess, // Set if we created the child process
NetworkStream? networkStream, // Set if using TCP
ProcessStderrPump? stderrPump = null) // Captures stderr for error messages
ProcessStderrPump? stderrPump = null, // Captures stderr for error messages
FfiRuntimeHost? ffiHost = null) // Set if using in-process FFI hosting
{
public Process? CliProcess => cliProcess;
public JsonRpc Rpc => rpc;
public ServerRpc Server => field ?? Interlocked.CompareExchange(ref field, new(rpc), null) ?? field;
public NetworkStream? NetworkStream => networkStream;
public ProcessStderrPump? StderrPump => stderrPump;
public StringBuilder? StderrBuffer => stderrPump?.Buffer;
public FfiRuntimeHost? FfiHost => ffiHost;
}

private sealed class ProcessStderrPump
Expand Down
Loading
Loading