Skip to content

Commit c284533

Browse files
dotnet: in-process FFI runtime hosting (InProcess transport) (#1901)
1 parent ebb0eca commit c284533

28 files changed

Lines changed: 1013 additions & 477 deletions

.github/workflows/dotnet-sdk-tests.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@ permissions:
2828

2929
jobs:
3030
test:
31-
name: ".NET SDK Tests"
31+
name: ".NET SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})"
3232
if: github.event.repository.fork == false
3333
env:
3434
POWERSHELL_UPDATECHECK: Off
3535
strategy:
3636
fail-fast: false
3737
matrix:
3838
os: [ubuntu-latest, macos-latest, windows-latest]
39+
transport: ["default", "inprocess"]
3940
runs-on: ${{ matrix.os }}
4041
defaults:
4142
run:
@@ -80,6 +81,10 @@ jobs:
8081
if: runner.os == 'Windows'
8182
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
8283

84+
- name: Select inprocess transport
85+
if: matrix.transport == 'inprocess'
86+
run: echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
87+
8388
- name: Run .NET SDK tests
8489
env:
8590
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}

dotnet/src/Client.cs

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
7979
private readonly List<LifecycleSubscription> _lifecycleHandlers = [];
8080

8181
private Task<Connection>? _connectionTask;
82+
private FfiRuntimeHost? _ffiHost;
8283
private bool _disposed;
8384
private int? _actualPort;
8485
private int? _negotiatedProtocolVersion;
@@ -135,13 +136,16 @@ private sealed record LifecycleSubscription(Type EventType, Action<SessionLifecy
135136
public CopilotClient(CopilotClientOptions? options = null)
136137
{
137138
_options = options ?? new();
138-
_connection = _options.Connection ?? RuntimeConnection.ForStdio();
139+
_connection = _options.Connection ?? ResolveDefaultConnection(_options);
139140

140141
switch (_connection)
141142
{
142143
case StdioRuntimeConnection:
143144
break;
144145

146+
case InProcessRuntimeConnection:
147+
break;
148+
145149
case TcpRuntimeConnection tcp:
146150
if (tcp.ConnectionToken is { Length: 0 })
147151
{
@@ -198,6 +202,38 @@ _options.SessionFs is not null ||
198202
}
199203
}
200204

205+
/// <summary>
206+
/// Environment variable that overrides the transport used when the caller does not
207+
/// specify <see cref="CopilotClientOptions.Connection"/>. Accepts <c>"inprocess"</c>
208+
/// or <c>"stdio"</c> (case-insensitive); unset preserves the default stdio transport.
209+
/// Any other value is an error. Ignored when a <see cref="RuntimeConnection"/> is set
210+
/// explicitly.
211+
/// </summary>
212+
internal const string DefaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION";
213+
214+
/// <summary>
215+
/// Resolves the default <see cref="RuntimeConnection"/> for the no-Connection case,
216+
/// honoring <see cref="DefaultConnectionEnvVar"/>.
217+
/// </summary>
218+
private static RuntimeConnection ResolveDefaultConnection(CopilotClientOptions options)
219+
{
220+
var value = options.Environment is not null
221+
&& options.Environment.TryGetValue(DefaultConnectionEnvVar, out var fromOptions)
222+
? fromOptions
223+
: Environment.GetEnvironmentVariable(DefaultConnectionEnvVar);
224+
225+
if (string.IsNullOrEmpty(value) || string.Equals(value, "stdio", StringComparison.OrdinalIgnoreCase))
226+
{
227+
return RuntimeConnection.ForStdio();
228+
}
229+
if (string.Equals(value, "inprocess", StringComparison.OrdinalIgnoreCase))
230+
{
231+
return RuntimeConnection.ForInProcess();
232+
}
233+
throw new ArgumentException(
234+
$"Invalid {DefaultConnectionEnvVar} value '{value}'. Expected 'inprocess', 'stdio', or unset.");
235+
}
236+
201237
/// <summary>
202238
/// Parses a runtime URL into a URI with host and port.
203239
/// </summary>
@@ -251,7 +287,16 @@ async Task<Connection> StartCoreAsync(CancellationToken ct)
251287

252288
try
253289
{
254-
if (_connection is UriRuntimeConnection)
290+
if (_connection is InProcessRuntimeConnection)
291+
{
292+
// In-process FFI hosting: load the Rust cdylib and let it spawn
293+
// the CLI worker, instead of the SDK launching a CLI child process.
294+
var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), _options.Environment, _logger);
295+
_ffiHost = ffiHost;
296+
await ffiHost.StartAsync(ct);
297+
connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost);
298+
}
299+
else if (_connection is UriRuntimeConnection)
255300
{
256301
// External runtime
257302
_actualPort = _optionsPort;
@@ -438,7 +483,7 @@ private async Task CleanupConnectionAsync(List<Exception>? errors, bool graceful
438483

439484
private async Task CleanupConnectionAsync(Connection ctx, List<Exception>? errors, bool gracefulRuntimeShutdown)
440485
{
441-
if (gracefulRuntimeShutdown && ctx.CliProcess is not null)
486+
if (gracefulRuntimeShutdown && (ctx.CliProcess is not null || ctx.FfiHost is not null))
442487
{
443488
var runtimeShutdownTimestamp = Stopwatch.GetTimestamp();
444489
try
@@ -478,6 +523,13 @@ or IOException
478523
{
479524
await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger);
480525
}
526+
527+
if (ctx.FfiHost is { } ffiHost)
528+
{
529+
try { ffiHost.Dispose(); }
530+
catch (Exception ex) { AddCleanupError(errors, ex, _logger); }
531+
_ffiHost = null;
532+
}
481533
}
482534

483535
private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List<Exception>? errors, ILogger? logger)
@@ -1795,12 +1847,14 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
17951847
int? serverVersion;
17961848
try
17971849
{
1798-
var token = _connection switch
1799-
{
1800-
TcpRuntimeConnection tcp => tcp.ConnectionToken,
1801-
UriRuntimeConnection uri => uri.ConnectionToken,
1802-
_ => null,
1803-
};
1850+
var token = _ffiHost is not null
1851+
? null // FFI hosting is an ungated in-process connection; no token.
1852+
: _connection switch
1853+
{
1854+
TcpRuntimeConnection tcp => tcp.ConnectionToken,
1855+
UriRuntimeConnection uri => uri.ConnectionToken,
1856+
_ => null,
1857+
};
18041858
var connectResponse = await InvokeRpcAsync<ConnectResult>(
18051859
connection.Rpc,
18061860
"connect",
@@ -2087,6 +2141,57 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex)
20872141
return arch != null ? $"{os}-{arch}" : null;
20882142
}
20892143

2144+
private string ResolveCliPathForFfi()
2145+
{
2146+
var envCliPath = _options.Environment is not null && _options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue)
2147+
? envValue
2148+
: System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
2149+
if (!string.IsNullOrEmpty(envCliPath))
2150+
{
2151+
return envCliPath;
2152+
}
2153+
2154+
// Fall back to the bundled single-file CLI the same way stdio discovers it.
2155+
// It embeds its own Node and is spawned directly as `copilot --embedded-host`,
2156+
// with the sibling cdylib loaded in-process (FfiRuntimeHost.Create prefers the
2157+
// flat `libcopilot_runtime.so`/`copilot_runtime.dll` next to the CLI, falling
2158+
// back to the dev `prebuilds/<folder>/runtime.node` layout).
2159+
var bundled = GetBundledCliPath(out var searchedPath);
2160+
return bundled
2161+
?? throw new InvalidOperationException(
2162+
"In-process FFI hosting requires the Copilot CLI. Set the COPILOT_CLI_PATH "
2163+
+ $"environment variable, or ensure the bundled CLI is present (looked in '{searchedPath}').");
2164+
}
2165+
2166+
/// <summary>
2167+
/// Returns the napi-rs prebuilds folder name for the current host — the
2168+
/// <c>&lt;node-platform&gt;-&lt;arch&gt;</c> convention (e.g. <c>win32-x64</c>,
2169+
/// <c>darwin-arm64</c>, <c>linux-x64</c>) under which the runtime ships
2170+
/// <c>prebuilds/&lt;folder&gt;/runtime.node</c>. This differs from the .NET RID
2171+
/// (<c>win-x64</c>/<c>osx-x64</c>) for Windows and macOS.
2172+
/// </summary>
2173+
private static string? GetNapiPrebuildsFolder()
2174+
{
2175+
string platform;
2176+
if (OperatingSystem.IsWindows()) platform = "win32";
2177+
else if (OperatingSystem.IsLinux()) platform = "linux";
2178+
else if (OperatingSystem.IsMacOS()) platform = "darwin";
2179+
else return null;
2180+
2181+
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
2182+
{
2183+
System.Runtime.InteropServices.Architecture.X64 => "x64",
2184+
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
2185+
_ => null,
2186+
};
2187+
2188+
return arch != null ? $"{platform}-{arch}" : null;
2189+
}
2190+
2191+
private static string GetNapiPrebuildsFolderOrThrow() =>
2192+
GetNapiPrebuildsFolder()
2193+
?? throw new InvalidOperationException("Could not determine a napi-rs prebuilds folder for FFI hosting.");
2194+
20902195
private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(string cliPath, IEnumerable<string> args)
20912196
{
20922197
var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase);
@@ -2099,7 +2204,7 @@ private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(str
20992204
return (cliPath, args);
21002205
}
21012206

2102-
private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken)
2207+
private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken, FfiRuntimeHost? ffiHost = null)
21032208
{
21042209
var setupTimestamp = Stopwatch.GetTimestamp();
21052210
NetworkStream? networkStream = null;
@@ -2109,7 +2214,12 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
21092214
{
21102215
Stream inputStream, outputStream;
21112216

2112-
if (_connection is StdioRuntimeConnection)
2217+
if (ffiHost is not null)
2218+
{
2219+
inputStream = ffiHost.ReceiveStream;
2220+
outputStream = ffiHost.SendStream;
2221+
}
2222+
else if (_connection is StdioRuntimeConnection)
21132223
{
21142224
if (cliProcess == null)
21152225
{
@@ -2175,7 +2285,7 @@ private async Task<Connection> ConnectToServerAsync(Process? cliProcess, string?
21752285
"CopilotClient.ConnectToServerAsync transport setup complete. Elapsed={Elapsed}",
21762286
setupTimestamp);
21772287

2178-
var connection = new Connection(rpc, cliProcess, networkStream, stderrPump);
2288+
var connection = new Connection(rpc, cliProcess, networkStream, stderrPump, ffiHost);
21792289
_serverRpc = connection.Server;
21802290

21812291
return connection;
@@ -2378,14 +2488,16 @@ private class Connection(
23782488
JsonRpc rpc,
23792489
Process? cliProcess, // Set if we created the child process
23802490
NetworkStream? networkStream, // Set if using TCP
2381-
ProcessStderrPump? stderrPump = null) // Captures stderr for error messages
2491+
ProcessStderrPump? stderrPump = null, // Captures stderr for error messages
2492+
FfiRuntimeHost? ffiHost = null) // Set if using in-process FFI hosting
23822493
{
23832494
public Process? CliProcess => cliProcess;
23842495
public JsonRpc Rpc => rpc;
23852496
public ServerRpc Server => field ?? Interlocked.CompareExchange(ref field, new(rpc), null) ?? field;
23862497
public NetworkStream? NetworkStream => networkStream;
23872498
public ProcessStderrPump? StderrPump => stderrPump;
23882499
public StringBuilder? StderrBuffer => stderrPump?.Buffer;
2500+
public FfiRuntimeHost? FfiHost => ffiHost;
23892501
}
23902502

23912503
private sealed class ProcessStderrPump

0 commit comments

Comments
 (0)