From 40b9b7a46db636a3784baf1867f521a7909f4032 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 3 Jul 2026 10:56:58 +0000 Subject: [PATCH 1/5] dotnet: add in-process FFI runtime hosting (InProcess transport) Add a new InProcess transport that loads the runtime cdylib (runtime.node) in-process and speaks JSON-RPC over its C ABI instead of spawning a CLI subprocess and talking over stdio/TCP. The Rust host_start call spawns the residual Node worker (napi-oop) itself; the C# side only drives opaque JSON-RPC blobs over the FFI boundary. - FfiRuntimeHost.cs: shared host body (library resolution, host_start/ connection_open handshake, duplex Channel-backed streams bridging the native outbound callback to the SDK's JsonRpc framing) with two interop backends: * net8.0/net10.0: source-generated [LibraryImport] P/Invoke with a DllImportResolver mapping the logical library name to the runtime.node absolute path and an [UnmanagedCallersOnly] cdecl function-pointer callback routed via a GCHandle. Trim/NativeAOT-compatible (IsAotCompatible). * netstandard2.0: classic delegate-based P/Invoke over a hand-rolled dlopen/LoadLibrary loader (netstandard2.0 has neither LibraryImport, NativeLibrary, nor UnmanagedCallersOnly), with the outbound callback held as an instance delegate for the connection's lifetime. - Types.cs: InProcessRuntimeConnection + RuntimeConnection.ForInProcess(path, args). - Client.cs: select the FFI transport for InProcessRuntimeConnection (or the COPILOT_SDK_FFI_HOST override), send a null connection token in FFI mode. - csproj: AllowUnsafeBlocks for the function-pointer callback. - ClientE2ETests: Should_Start_And_Connect_Over_InProcess_Ffi. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 102 ++++- dotnet/src/FfiRuntimeHost.cs | 626 +++++++++++++++++++++++++++ dotnet/src/GitHub.Copilot.SDK.csproj | 1 + dotnet/src/Types.cs | 23 + dotnet/test/E2E/ClientE2ETests.cs | 33 ++ 5 files changed, 772 insertions(+), 13 deletions(-) create mode 100644 dotnet/src/FfiRuntimeHost.cs diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 6041fe2391..e330c2b287 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -79,6 +79,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable private readonly List _lifecycleHandlers = []; private Task? _connectionTask; + private FfiRuntimeHost? _ffiHost; private bool _disposed; private int? _actualPort; private int? _negotiatedProtocolVersion; @@ -135,13 +136,16 @@ private sealed record LifecycleSubscription(Type EventType, Action + /// Environment variable that overrides the transport used when the caller does not + /// specify . Accepts "inprocess" + /// or "stdio" (case-insensitive); unset preserves the default stdio transport. + /// Any other value is an error. Ignored when a is set + /// explicitly. + /// + internal const string DefaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION"; + + /// + /// Resolves the default for the no-Connection case, + /// honoring . + /// + 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."); + } + /// /// Parses a runtime URL into a URI with host and port. /// @@ -251,7 +287,16 @@ async Task StartCoreAsync(CancellationToken ct) try { - if (_connection is UriRuntimeConnection) + if (_connection is InProcessRuntimeConnection) + { + // In-process FFI hosting: load the Rust cdylib and let it spawn + // the Node worker, instead of the SDK launching a CLI child process. + var ffiHost = FfiRuntimeHost.Create(ResolveCliJsPathForFfi(), GetPortableRidOrThrow(), _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; @@ -438,7 +483,7 @@ private async Task CleanupConnectionAsync(List? errors, bool graceful private async Task CleanupConnectionAsync(Connection ctx, List? 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 @@ -478,6 +523,13 @@ or IOException { await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger); } + + if (ctx.FfiHost is { } ffiHost) + { + try { ffiHost.Dispose(); } + catch (Exception ex) { AddCleanupError(errors, ex, _logger); } + _ffiHost = null; + } } private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List? errors, ILogger? logger) @@ -1795,12 +1847,14 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio 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( connection.Rpc, "connect", [new ConnectRequest { Token = token }], connection.StderrBuffer, cancellationToken); serverVersion = (int)connectResponse.ProtocolVersion; @@ -2077,6 +2131,21 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) return arch != null ? $"{os}-{arch}" : null; } + private string ResolveCliJsPathForFfi() + { + var envCliPath = _options.Environment is not null && _options.Environment.TryGetValue("COPILOT_CLI_PATH", out var envValue) + ? envValue + : System.Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); + return envCliPath + ?? throw new InvalidOperationException( + "In-process FFI hosting requires a JavaScript CLI entrypoint. " + + "Set the COPILOT_CLI_PATH environment variable to the dist-cli/index.js path."); + } + + private static string GetPortableRidOrThrow() => + GetPortableRid() + ?? throw new InvalidOperationException("Could not determine a portable runtime identifier for FFI hosting."); + private static (string FileName, IEnumerable Args) ResolveCliCommand(string cliPath, IEnumerable args) { var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase); @@ -2089,7 +2158,7 @@ private static (string FileName, IEnumerable Args) ResolveCliCommand(str return (cliPath, args); } - private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken) + private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, ProcessStderrPump? stderrPump, CancellationToken cancellationToken, FfiRuntimeHost? ffiHost = null) { var setupTimestamp = Stopwatch.GetTimestamp(); NetworkStream? networkStream = null; @@ -2099,7 +2168,12 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? { 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) { @@ -2165,7 +2239,7 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? "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; @@ -2368,7 +2442,8 @@ private class Connection( 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; @@ -2376,6 +2451,7 @@ private class Connection( public NetworkStream? NetworkStream => networkStream; public ProcessStderrPump? StderrPump => stderrPump; public StringBuilder? StderrBuffer => stderrPump?.Buffer; + public FfiRuntimeHost? FfiHost => ffiHost; } private sealed class ProcessStderrPump diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs new file mode 100644 index 0000000000..22d9715368 --- /dev/null +++ b/dotnet/src/FfiRuntimeHost.cs @@ -0,0 +1,626 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.Logging; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; + +namespace GitHub.Copilot; + +/// +/// Hosts the Copilot runtime in-process by loading the Rust cdylib (runtime.node) +/// and speaking JSON-RPC over its C ABI (FFI) instead of spawning a CLI child process +/// and communicating over stdio/TCP. +/// +/// +/// The Rust host_start export spawns the residual Node/TypeScript worker itself +/// (identically to copilot-runtime-bin), so the .NET host never launches Node +/// directly. JSON-RPC frames are pumped across the ABI: writes go to +/// connection_write; inbound frames arrive on a native callback that feeds +/// . +/// +/// The native interop layer has two implementations selected by target framework. On +/// modern .NET it uses source-generated LibraryImport P/Invoke with an +/// UnmanagedCallersOnly function-pointer callback, which is trim- and +/// NativeAOT-compatible. On netstandard2.0 (which has neither LibraryImport +/// nor NativeLibrary) it falls back to classic delegate-based P/Invoke over a +/// hand-rolled dlopen/LoadLibrary loader. Because the library lives at a +/// runtime-resolved absolute path, the modern path maps the logical +/// via a resolver and the legacy path loads the absolute path +/// directly. +/// +/// +internal sealed partial class FfiRuntimeHost : IDisposable +{ + /// Logical name the native interop layer binds the cdylib to. + private const string LibraryName = "copilot_runtime"; + + private readonly ILogger _logger; + private readonly string _cliJsPath; + private readonly string _libraryPath; + private readonly string _providerPath; + private readonly IReadOnlyDictionary? _environment; + + private readonly CallbackReceiveStream _receiveStream = new(); + private CallbackSendStream? _sendStream; + + private uint _serverId; + private uint _connectionId; + private bool _disposed; + + private FfiRuntimeHost(string libraryPath, string cliJsPath, string providerPath, IReadOnlyDictionary? environment, ILogger logger) + { + _libraryPath = libraryPath; + _cliJsPath = cliJsPath; + _providerPath = providerPath; + _environment = environment; + _logger = logger; + } + + /// The stream JSON-RPC reads server→client frames from. + public Stream ReceiveStream => _receiveStream; + + /// The stream JSON-RPC writes client→server frames to. + public Stream SendStream => _sendStream + ?? throw new InvalidOperationException("FfiRuntimeHost has not been started."); + + /// + /// Loads the cdylib next to the given CLI JavaScript entrypoint and prepares the + /// FFI host. The entrypoint must be a .js file (e.g. dist-cli/index.js) + /// so that the sibling prebuilds/<rid>/runtime.node and + /// copilot-runtime-bin can be resolved. + /// + public static FfiRuntimeHost Create(string cliJsPath, string portableRid, IReadOnlyDictionary? environment, ILogger logger) + { + if (!cliJsPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"In-process FFI hosting requires a JavaScript entrypoint (e.g. dist-cli/index.js), but got '{cliJsPath}'."); + } + + var distDir = Path.GetDirectoryName(Path.GetFullPath(cliJsPath)) + ?? throw new InvalidOperationException($"Could not determine directory for '{cliJsPath}'."); + var prebuildsDir = Path.Combine(distDir, "prebuilds", portableRid); + var libraryPath = Path.Combine(prebuildsDir, "runtime.node"); + var providerName = OperatingSystem.IsWindows() ? "copilot-runtime-bin.exe" : "copilot-runtime-bin"; + var providerPath = Path.Combine(prebuildsDir, providerName); + + if (!File.Exists(libraryPath)) + { + throw new InvalidOperationException($"FFI runtime library not found at '{libraryPath}'."); + } + if (!File.Exists(providerPath)) + { + throw new InvalidOperationException($"FFI runtime provider not found at '{providerPath}'."); + } + + PrepareNativeLibrary(libraryPath); + return new FfiRuntimeHost(libraryPath, Path.GetFullPath(cliJsPath), providerPath, environment, logger); + } + + /// + /// Starts the in-process runtime: spawns the Node worker via the Rust host, + /// waits for readiness, and opens the FFI JSON-RPC connection. + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + // host_start blocks until the Node worker connects back and signals readiness + // (up to ~30s), and connection_open must run outside any async runtime, so + // perform the blocking FFI handshake on a background thread. + await Task.Run(() => + { + var argvJson = BuildArgvJson(_cliJsPath); + var providerBytes = Encoding.UTF8.GetBytes(_providerPath); + var envJson = BuildEnvJson(_environment); + + _serverId = NativeHostStart(argvJson, providerBytes, envJson); + if (_serverId == 0) + { + throw new InvalidOperationException( + $"copilot_runtime_host_start failed (library '{_libraryPath}', provider '{_providerPath}')."); + } + + _connectionId = NativeOpenConnection(_serverId); + if (_connectionId == 0) + { + DisposeNativeCallback(); + NativeHostShutdown(_serverId); + _serverId = 0; + throw new InvalidOperationException("copilot_runtime_connection_open failed."); + } + + _sendStream = new CallbackSendStream(SendFrame); + }, cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "FfiRuntimeHost started. Library={Library}, ServerId={ServerId}, ConnectionId={ConnectionId}", + _libraryPath, _serverId, _connectionId); + } + } + + private static byte[] BuildArgvJson(string cliJsPath) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartArray(); + writer.WriteStringValue("node"); + writer.WriteStringValue(cliJsPath); + writer.WriteStringValue("--embedded-host"); + writer.WriteEndArray(); + } + return stream.ToArray(); + } + + private static byte[]? BuildEnvJson(IReadOnlyDictionary? environment) + { + if (environment is null || environment.Count == 0) + { + return null; + } + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (var kvp in environment) + { + writer.WriteString(kvp.Key, kvp.Value); + } + writer.WriteEndObject(); + } + return stream.ToArray(); + } + + private bool SendFrame(byte[] frame) + { + if (_disposed || _connectionId == 0) + { + return false; + } + return NativeConnectionWrite(_connectionId, frame); + } + + private void FeedInbound(IntPtr bytesPtr, UIntPtr bytesLen) + { + var length = checked((int)bytesLen.ToUInt64()); + var buffer = new byte[length]; + Marshal.Copy(bytesPtr, buffer, 0, length); + _receiveStream.Feed(buffer); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + try + { + if (_connectionId != 0) + { + NativeConnectionClose(_connectionId); + _connectionId = 0; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "FfiRuntimeHost: connection_close failed"); + } + + try + { + if (_serverId != 0) + { + NativeHostShutdown(_serverId); + _serverId = 0; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "FfiRuntimeHost: host_shutdown failed"); + } + + _receiveStream.Complete(); + DisposeNativeCallback(); + } + + /// Length as the native pointer-sized unsigned integer the ABI expects. + private static UIntPtr Len(int value) => new((uint)value); + +#if NET + // ---- Modern interop: source-generated LibraryImport P/Invoke (trim/AOT-safe) ---- + + private static readonly object ResolverLock = new(); + private static bool s_resolverRegistered; + private static string? s_resolvedLibraryPath; + + // A normal (non-pinned) handle to this instance, passed to the native side as + // the callback's user_data so the static outbound callback can route back here. + private GCHandle _selfHandle; + + /// + /// Registers (once) a process-wide + /// that maps to the absolute runtime.node path so the + /// stubs resolve. The resolved handle is cached by + /// the runtime after first use, so all in-process hosts share a single loaded library. + /// + private static void PrepareNativeLibrary(string libraryPath) + { + lock (ResolverLock) + { + s_resolvedLibraryPath = libraryPath; + if (!s_resolverRegistered) + { + NativeLibrary.SetDllImportResolver(typeof(FfiRuntimeHost).Assembly, Resolve); + s_resolverRegistered = true; + } + } + } + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName == LibraryName && s_resolvedLibraryPath is not null) + { + return NativeLibrary.Load(s_resolvedLibraryPath); + } + return IntPtr.Zero; + } + + private static uint NativeHostStart(byte[] argvJson, byte[] provider, byte[]? env) => + HostStart(argvJson, Len(argvJson.Length), provider, Len(provider.Length), env, env is null ? UIntPtr.Zero : Len(env.Length)); + + private uint NativeOpenConnection(uint serverId) + { + _selfHandle = GCHandle.Alloc(this); + unsafe + { + return ConnectionOpen( + serverId, + &OnOutboundStatic, + GCHandle.ToIntPtr(_selfHandle), + null, UIntPtr.Zero, + null, UIntPtr.Zero, + null, UIntPtr.Zero); + } + } + + private static bool NativeHostShutdown(uint serverId) => HostShutdown(serverId); + + private static bool NativeConnectionWrite(uint connectionId, byte[] frame) => ConnectionWrite(connectionId, frame, Len(frame.Length)); + + private static bool NativeConnectionClose(uint connectionId) => ConnectionClose(connectionId); + + private void DisposeNativeCallback() + { + if (_selfHandle.IsAllocated) + { + _selfHandle.Free(); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnOutboundStatic(IntPtr userData, IntPtr bytesPtr, nuint bytesLen) + { + if (userData == IntPtr.Zero || bytesPtr == IntPtr.Zero || bytesLen == 0) + { + return; + } + if (GCHandle.FromIntPtr(userData).Target is FfiRuntimeHost self) + { + self.FeedInbound(bytesPtr, bytesLen); + } + } + + [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_host_start")] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + private static partial uint HostStart( + byte[] argvJson, nuint argvJsonLen, + byte[] provider, nuint providerLen, + byte[]? env, nuint envLen); + + [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_host_shutdown")] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool HostShutdown(uint serverId); + + [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_connection_open")] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + private static unsafe partial uint ConnectionOpen( + uint serverId, + delegate* unmanaged[Cdecl] onOutbound, + IntPtr userData, + byte[]? extSource, nuint extSourceLen, + byte[]? extName, nuint extNameLen, + byte[]? connToken, nuint connTokenLen); + + [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_connection_write")] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool ConnectionWrite(uint connectionId, byte[] bytes, nuint bytesLen); + + [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_connection_close")] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + [return: MarshalAs(UnmanagedType.U1)] + private static partial bool ConnectionClose(uint connectionId); +#else + // ---- Legacy interop: delegate-based P/Invoke for netstandard2.0 ---- + // netstandard2.0 has neither LibraryImport, NativeLibrary, nor UnmanagedCallersOnly, + // so the cdylib is loaded through a hand-rolled dlopen/LoadLibrary shim and each + // export is bound to a [UnmanagedFunctionPointer] delegate. The outbound callback is + // an instance delegate kept alive in a field for the connection's lifetime. + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate uint HostStartDelegate( + byte[] argvJson, UIntPtr argvJsonLen, + byte[] provider, UIntPtr providerLen, + byte[]? env, UIntPtr envLen); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.U1)] + private delegate bool HostShutdownDelegate(uint serverId); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate uint ConnectionOpenDelegate( + uint serverId, + OutboundCallbackDelegate onOutbound, + IntPtr userData, + byte[]? extSource, UIntPtr extSourceLen, + byte[]? extName, UIntPtr extNameLen, + byte[]? connToken, UIntPtr connTokenLen); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.U1)] + private delegate bool ConnectionWriteDelegate(uint connectionId, byte[] bytes, UIntPtr bytesLen); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.U1)] + private delegate bool ConnectionCloseDelegate(uint connectionId); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void OutboundCallbackDelegate(IntPtr userData, IntPtr bytesPtr, UIntPtr bytesLen); + + private static readonly object NativeLock = new(); + private static bool s_loaded; + private static HostStartDelegate? s_hostStart; + private static HostShutdownDelegate? s_hostShutdown; + private static ConnectionOpenDelegate? s_connectionOpen; + private static ConnectionWriteDelegate? s_connectionWrite; + private static ConnectionCloseDelegate? s_connectionClose; + + // Held for the connection's lifetime so the marshaled function pointer handed to the + // native side is not collected while Rust may still invoke it. + private OutboundCallbackDelegate? _outboundDelegate; + + private static void PrepareNativeLibrary(string libraryPath) + { + lock (NativeLock) + { + if (s_loaded) + { + return; + } + + var handle = NativeLoader.Load(libraryPath); + if (handle == IntPtr.Zero) + { + throw new InvalidOperationException($"Failed to load FFI runtime library '{libraryPath}'."); + } + + s_hostStart = Bind(handle, "copilot_runtime_host_start"); + s_hostShutdown = Bind(handle, "copilot_runtime_host_shutdown"); + s_connectionOpen = Bind(handle, "copilot_runtime_connection_open"); + s_connectionWrite = Bind(handle, "copilot_runtime_connection_write"); + s_connectionClose = Bind(handle, "copilot_runtime_connection_close"); + s_loaded = true; + } + } + + private static T Bind(IntPtr handle, string export) where T : Delegate + { + var symbol = NativeLoader.GetSymbol(handle, export); + if (symbol == IntPtr.Zero) + { + throw new InvalidOperationException($"FFI runtime library is missing the '{export}' export."); + } + return Marshal.GetDelegateForFunctionPointer(symbol); + } + + private static uint NativeHostStart(byte[] argvJson, byte[] provider, byte[]? env) => + s_hostStart!(argvJson, Len(argvJson.Length), provider, Len(provider.Length), env, env is null ? UIntPtr.Zero : Len(env.Length)); + + private uint NativeOpenConnection(uint serverId) + { + _outboundDelegate = OnOutbound; + return s_connectionOpen!( + serverId, + _outboundDelegate, + IntPtr.Zero, + null, UIntPtr.Zero, + null, UIntPtr.Zero, + null, UIntPtr.Zero); + } + + private static bool NativeHostShutdown(uint serverId) => s_hostShutdown!(serverId); + + private static bool NativeConnectionWrite(uint connectionId, byte[] frame) => s_connectionWrite!(connectionId, frame, Len(frame.Length)); + + private static bool NativeConnectionClose(uint connectionId) => s_connectionClose!(connectionId); + + private void DisposeNativeCallback() => _outboundDelegate = null; + + private void OnOutbound(IntPtr userData, IntPtr bytesPtr, UIntPtr bytesLen) + { + if (bytesPtr == IntPtr.Zero || bytesLen == UIntPtr.Zero) + { + return; + } + FeedInbound(bytesPtr, bytesLen); + } + + /// + /// Minimal cross-platform native library loader for netstandard2.0, which lacks + /// NativeLibrary. Uses LoadLibrary/GetProcAddress on Windows + /// and dlopen/dlsym elsewhere (trying libdl.so.2 first, then + /// libdl for older Linux and macOS). + /// + private static class NativeLoader + { + public static IntPtr Load(string path) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Windows.LoadLibrary(path) : Unix.Open(path); + + public static IntPtr GetSymbol(IntPtr handle, string name) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Windows.GetProcAddress(handle, name) : Unix.Sym(handle, name); + + private static class Windows + { + [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode, BestFitMapping = false, ThrowOnUnmappableChar = true)] + public static extern IntPtr LoadLibrary([MarshalAs(UnmanagedType.LPWStr)] string path); + + [DllImport("kernel32", SetLastError = true, BestFitMapping = false, ThrowOnUnmappableChar = true)] + public static extern IntPtr GetProcAddress(IntPtr module, [MarshalAs(UnmanagedType.LPStr)] string name); + } + + private static class Unix + { + private const int RtldNow = 2; + + public static IntPtr Open(string path) + { + try { return Libdl2.dlopen(path, RtldNow); } + catch (DllNotFoundException) { return Libdl1.dlopen(path, RtldNow); } + } + + public static IntPtr Sym(IntPtr handle, string name) + { + try { return Libdl2.dlsym(handle, name); } + catch (DllNotFoundException) { return Libdl1.dlsym(handle, name); } + } + + private static class Libdl2 + { + [DllImport("libdl.so.2", EntryPoint = "dlopen", CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)] + public static extern IntPtr dlopen([MarshalAs(UnmanagedType.LPStr)] string fileName, int flags); + + [DllImport("libdl.so.2", EntryPoint = "dlsym", CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)] + public static extern IntPtr dlsym(IntPtr handle, [MarshalAs(UnmanagedType.LPStr)] string symbol); + } + + private static class Libdl1 + { + [DllImport("libdl", EntryPoint = "dlopen", CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)] + public static extern IntPtr dlopen([MarshalAs(UnmanagedType.LPStr)] string fileName, int flags); + + [DllImport("libdl", EntryPoint = "dlsym", CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)] + public static extern IntPtr dlsym(IntPtr handle, [MarshalAs(UnmanagedType.LPStr)] string symbol); + } + } + } +#endif + + /// + /// A read-only stream fed by the native outbound callback. Chunks are queued on + /// an unbounded channel and drained in order by the JSON-RPC read loop. + /// + private sealed class CallbackReceiveStream : Stream + { + private readonly Channel _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + private ReadOnlyMemory _leftover; + + public void Feed(byte[] data) => _channel.Writer.TryWrite(data); + + public void Complete() => _channel.Writer.TryComplete(); + +#if !NETSTANDARD2_0 + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return await ReadCoreAsync(buffer, cancellationToken).ConfigureAwait(false); + } +#endif + + private async ValueTask ReadCoreAsync(Memory buffer, CancellationToken cancellationToken) + { + if (_leftover.IsEmpty) + { + if (!await _channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + return 0; // EOF + } + if (!_channel.Reader.TryRead(out var chunk)) + { + return 0; + } + _leftover = chunk; + } + + var n = Math.Min(buffer.Length, _leftover.Length); + _leftover.Span.Slice(0, n).CopyTo(buffer.Span); + _leftover = _leftover.Slice(n); + return n; + } + + public override int Read(byte[] buffer, int offset, int count) => + ReadCoreAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadCoreAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// + /// A write-only stream that forwards each frame to the native + /// connection_write export. + /// + private sealed class CallbackSendStream(Func write) : Stream + { + public override void Write(byte[] buffer, int offset, int count) + { + var frame = new byte[count]; + Array.Copy(buffer, offset, frame, 0, count); + write(frame); + } + +#if !NETSTANDARD2_0 + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + write(buffer.ToArray()); + return ValueTask.CompletedTask; + } +#endif + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + Write(buffer, offset, count); + return Task.CompletedTask; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } +} diff --git a/dotnet/src/GitHub.Copilot.SDK.csproj b/dotnet/src/GitHub.Copilot.SDK.csproj index 7a9fa2bdca..f48fb802d7 100644 --- a/dotnet/src/GitHub.Copilot.SDK.csproj +++ b/dotnet/src/GitHub.Copilot.SDK.csproj @@ -16,6 +16,7 @@ copilot.png github;copilot;sdk;jsonrpc;agent true + true true snupkg true diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 37cb0ebe67..661e2c4a97 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -145,6 +145,18 @@ public static TcpRuntimeConnection ForTcp(int port = 0, string? connectionToken /// Optional shared secret to authenticate the connection. public static UriRuntimeConnection ForUri(string url, string? connectionToken = null) => new() { Url = url, ConnectionToken = connectionToken }; + + /// + /// Host the runtime in-process by loading its native library and communicating + /// over the C ABI (FFI) — no child process is spawned by the SDK for JSON-RPC + /// transport. The bundled runtime is used; to point at a non-default runtime + /// entrypoint, set the COPILOT_CLI_PATH environment variable. + /// + /// + /// Only supported on .NET 8.0 or later (requires NativeLibrary). + /// + public static InProcessRuntimeConnection ForInProcess() + => new(); } /// @@ -170,6 +182,17 @@ public sealed class StdioRuntimeConnection : ChildProcessRuntimeConnection internal StdioRuntimeConnection() { } } +/// +/// Hosts the runtime in-process by loading its native library and communicating +/// over the C ABI (FFI). Construct via . +/// Only supported on .NET 8.0 or later. To point at a non-default runtime entrypoint, +/// set the COPILOT_CLI_PATH environment variable. +/// +public sealed class InProcessRuntimeConnection : RuntimeConnection +{ + internal InProcessRuntimeConnection() { } +} + /// /// Spawns a runtime child process listening on a TCP socket. Construct via /// . diff --git a/dotnet/test/E2E/ClientE2ETests.cs b/dotnet/test/E2E/ClientE2ETests.cs index 9972e3b336..ca2d657774 100644 --- a/dotnet/test/E2E/ClientE2ETests.cs +++ b/dotnet/test/E2E/ClientE2ETests.cs @@ -33,6 +33,39 @@ public async Task Should_Start_And_Connect_To_Server(bool useStdio) } } + [Fact] + public async Task Should_Start_And_Connect_Over_InProcess_Ffi() + { + // In-process FFI hosting requires a JavaScript CLI entrypoint (dist-cli/index.js) + // with the sibling native runtime library. Skip when not available locally. + var cliJsPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); + if (string.IsNullOrEmpty(cliJsPath) || !cliJsPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) + { + // In-process FFI hosting requires a JavaScript CLI entrypoint (dist-cli/index.js) + // with the sibling native runtime library; not available in this environment. + return; + } + + using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForInProcess(), + }); + + try + { + await client.StartAsync(); + var pong = await client.PingAsync("ffi message"); + Assert.Equal("pong: ffi message", pong.Message); + Assert.NotEqual(default, pong.Timestamp); + + await client.StopAsync(); + } + finally + { + await client.ForceStopAsync(); + } + } + [Theory] [InlineData(true)] // stdio transport [InlineData(false)] // TCP transport From fed63c1728d897653975732e7d31f8aadea0180f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 3 Jul 2026 13:14:05 +0000 Subject: [PATCH 2/5] dotnet: FFI in-proc host spawns the bundled CLI, drops copilot-runtime-bin The in-process FFI transport no longer resolves or passes a `copilot-runtime-bin` provider path. The child manifest is delivered over the napi-oop handshake, so host_start only needs the CLI entrypoint and env. - FfiRuntimeHost: drop the provider field/param and the provider argument from the C ABI bindings (modern LibraryImport + netstandard2.0 delegate); accept a binary-or-.js entrypoint. A .js entrypoint is launched via `node` (dev/dist-cli); the packaged single-file CLI binary is invoked directly as `copilot --embedded-host` (it embeds its own Node). - Client: FFI path resolution falls back to the bundled CLI (runtimes//native/copilot) the same way stdio discovers it, still overridable via COPILOT_CLI_PATH. Renamed ResolveCliJsPathForFfi -> ResolveCliPathForFfi. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 21 +++++++--- dotnet/src/FfiRuntimeHost.cs | 81 ++++++++++++++++-------------------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index e330c2b287..43b94a1773 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -290,8 +290,8 @@ async Task StartCoreAsync(CancellationToken ct) if (_connection is InProcessRuntimeConnection) { // In-process FFI hosting: load the Rust cdylib and let it spawn - // the Node worker, instead of the SDK launching a CLI child process. - var ffiHost = FfiRuntimeHost.Create(ResolveCliJsPathForFfi(), GetPortableRidOrThrow(), _options.Environment, _logger); + // the CLI worker, instead of the SDK launching a CLI child process. + var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetPortableRidOrThrow(), _options.Environment, _logger); _ffiHost = ffiHost; await ffiHost.StartAsync(ct); connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost); @@ -2131,15 +2131,24 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) return arch != null ? $"{os}-{arch}" : null; } - private string ResolveCliJsPathForFfi() + 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"); - return envCliPath + 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//runtime.node` cdylib loaded in-process. + var bundled = GetBundledCliPath(out var searchedPath); + return bundled ?? throw new InvalidOperationException( - "In-process FFI hosting requires a JavaScript CLI entrypoint. " - + "Set the COPILOT_CLI_PATH environment variable to the dist-cli/index.js path."); + "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}')."); } private static string GetPortableRidOrThrow() => diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs index 22d9715368..ef7c48c893 100644 --- a/dotnet/src/FfiRuntimeHost.cs +++ b/dotnet/src/FfiRuntimeHost.cs @@ -6,7 +6,6 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Text; using System.Text.Json; using System.Threading.Channels; @@ -18,10 +17,11 @@ namespace GitHub.Copilot; /// and communicating over stdio/TCP. /// /// -/// The Rust host_start export spawns the residual Node/TypeScript worker itself -/// (identically to copilot-runtime-bin), so the .NET host never launches Node -/// directly. JSON-RPC frames are pumped across the ABI: writes go to -/// connection_write; inbound frames arrive on a native callback that feeds +/// The Rust host_start export spawns the residual TypeScript worker itself — +/// typically the packaged single-file CLI (copilot --embedded-host, which embeds +/// its own Node) or, for dev, node dist-cli/index.js --embedded-host — so the .NET +/// host never launches Node directly. JSON-RPC frames are pumped across the ABI: writes go +/// to connection_write; inbound frames arrive on a native callback that feeds /// . /// /// The native interop layer has two implementations selected by target framework. On @@ -41,9 +41,8 @@ internal sealed partial class FfiRuntimeHost : IDisposable private const string LibraryName = "copilot_runtime"; private readonly ILogger _logger; - private readonly string _cliJsPath; + private readonly string _cliEntrypoint; private readonly string _libraryPath; - private readonly string _providerPath; private readonly IReadOnlyDictionary? _environment; private readonly CallbackReceiveStream _receiveStream = new(); @@ -53,11 +52,10 @@ internal sealed partial class FfiRuntimeHost : IDisposable private uint _connectionId; private bool _disposed; - private FfiRuntimeHost(string libraryPath, string cliJsPath, string providerPath, IReadOnlyDictionary? environment, ILogger logger) + private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary? environment, ILogger logger) { _libraryPath = libraryPath; - _cliJsPath = cliJsPath; - _providerPath = providerPath; + _cliEntrypoint = cliEntrypoint; _environment = environment; _logger = logger; } @@ -70,59 +68,48 @@ private FfiRuntimeHost(string libraryPath, string cliJsPath, string providerPath ?? throw new InvalidOperationException("FfiRuntimeHost has not been started."); /// - /// Loads the cdylib next to the given CLI JavaScript entrypoint and prepares the - /// FFI host. The entrypoint must be a .js file (e.g. dist-cli/index.js) - /// so that the sibling prebuilds/<rid>/runtime.node and - /// copilot-runtime-bin can be resolved. + /// Loads the cdylib next to the given CLI entrypoint and prepares the FFI host. + /// The entrypoint is either the packaged single-file CLI binary (e.g. + /// runtimes/<rid>/native/copilot) or, for dev, a .js file (e.g. + /// dist-cli/index.js) launched via node. Either way the sibling + /// prebuilds/<rid>/runtime.node cdylib is resolved relative to it. /// - public static FfiRuntimeHost Create(string cliJsPath, string portableRid, IReadOnlyDictionary? environment, ILogger logger) + public static FfiRuntimeHost Create(string cliEntrypoint, string portableRid, IReadOnlyDictionary? environment, ILogger logger) { - if (!cliJsPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"In-process FFI hosting requires a JavaScript entrypoint (e.g. dist-cli/index.js), but got '{cliJsPath}'."); - } - - var distDir = Path.GetDirectoryName(Path.GetFullPath(cliJsPath)) - ?? throw new InvalidOperationException($"Could not determine directory for '{cliJsPath}'."); + var fullEntrypoint = Path.GetFullPath(cliEntrypoint); + var distDir = Path.GetDirectoryName(fullEntrypoint) + ?? throw new InvalidOperationException($"Could not determine directory for '{cliEntrypoint}'."); var prebuildsDir = Path.Combine(distDir, "prebuilds", portableRid); var libraryPath = Path.Combine(prebuildsDir, "runtime.node"); - var providerName = OperatingSystem.IsWindows() ? "copilot-runtime-bin.exe" : "copilot-runtime-bin"; - var providerPath = Path.Combine(prebuildsDir, providerName); if (!File.Exists(libraryPath)) { throw new InvalidOperationException($"FFI runtime library not found at '{libraryPath}'."); } - if (!File.Exists(providerPath)) - { - throw new InvalidOperationException($"FFI runtime provider not found at '{providerPath}'."); - } PrepareNativeLibrary(libraryPath); - return new FfiRuntimeHost(libraryPath, Path.GetFullPath(cliJsPath), providerPath, environment, logger); + return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, logger); } /// - /// Starts the in-process runtime: spawns the Node worker via the Rust host, + /// Starts the in-process runtime: spawns the CLI worker via the Rust host, /// waits for readiness, and opens the FFI JSON-RPC connection. /// public async Task StartAsync(CancellationToken cancellationToken) { - // host_start blocks until the Node worker connects back and signals readiness + // host_start blocks until the worker connects back and signals readiness // (up to ~30s), and connection_open must run outside any async runtime, so // perform the blocking FFI handshake on a background thread. await Task.Run(() => { - var argvJson = BuildArgvJson(_cliJsPath); - var providerBytes = Encoding.UTF8.GetBytes(_providerPath); + var argvJson = BuildArgvJson(_cliEntrypoint); var envJson = BuildEnvJson(_environment); - _serverId = NativeHostStart(argvJson, providerBytes, envJson); + _serverId = NativeHostStart(argvJson, envJson); if (_serverId == 0) { throw new InvalidOperationException( - $"copilot_runtime_host_start failed (library '{_libraryPath}', provider '{_providerPath}')."); + $"copilot_runtime_host_start failed (library '{_libraryPath}', entrypoint '{_cliEntrypoint}')."); } _connectionId = NativeOpenConnection(_serverId); @@ -145,14 +132,20 @@ await Task.Run(() => } } - private static byte[] BuildArgvJson(string cliJsPath) + private static byte[] BuildArgvJson(string cliEntrypoint) { + // A .js entrypoint (dev / dist-cli) is launched via node; the packaged + // single-file CLI binary embeds its own Node and is invoked directly. + var isJsFile = cliEntrypoint.EndsWith(".js", StringComparison.OrdinalIgnoreCase); using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) { writer.WriteStartArray(); - writer.WriteStringValue("node"); - writer.WriteStringValue(cliJsPath); + if (isJsFile) + { + writer.WriteStringValue("node"); + } + writer.WriteStringValue(cliEntrypoint); writer.WriteStringValue("--embedded-host"); writer.WriteEndArray(); } @@ -275,8 +268,8 @@ private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSe return IntPtr.Zero; } - private static uint NativeHostStart(byte[] argvJson, byte[] provider, byte[]? env) => - HostStart(argvJson, Len(argvJson.Length), provider, Len(provider.Length), env, env is null ? UIntPtr.Zero : Len(env.Length)); + private static uint NativeHostStart(byte[] argvJson, byte[]? env) => + HostStart(argvJson, Len(argvJson.Length), env, env is null ? UIntPtr.Zero : Len(env.Length)); private uint NativeOpenConnection(uint serverId) { @@ -324,7 +317,6 @@ private static void OnOutboundStatic(IntPtr userData, IntPtr bytesPtr, nuint byt [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] private static partial uint HostStart( byte[] argvJson, nuint argvJsonLen, - byte[] provider, nuint providerLen, byte[]? env, nuint envLen); [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_host_shutdown")] @@ -361,7 +353,6 @@ private static unsafe partial uint ConnectionOpen( [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate uint HostStartDelegate( byte[] argvJson, UIntPtr argvJsonLen, - byte[] provider, UIntPtr providerLen, byte[]? env, UIntPtr envLen); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] @@ -434,8 +425,8 @@ private static T Bind(IntPtr handle, string export) where T : Delegate return Marshal.GetDelegateForFunctionPointer(symbol); } - private static uint NativeHostStart(byte[] argvJson, byte[] provider, byte[]? env) => - s_hostStart!(argvJson, Len(argvJson.Length), provider, Len(provider.Length), env, env is null ? UIntPtr.Zero : Len(env.Length)); + private static uint NativeHostStart(byte[] argvJson, byte[]? env) => + s_hostStart!(argvJson, Len(argvJson.Length), env, env is null ? UIntPtr.Zero : Len(env.Length)); private uint NativeOpenConnection(uint serverId) { From b673393fc023b095cab3789eebf7dca59a064305 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 3 Jul 2026 13:24:05 +0000 Subject: [PATCH 3/5] dotnet: emit the FFI cdylib as a natural platform shared library The in-process FFI host loads the Rust cdylib in the .NET process, so the build must emit it next to the CLI binary. The MSBuild targets copy the tarball's napi-style prebuilds//runtime.node to a flat, natural shared-library name in runtimes//native/: win -> copilot_runtime.dll linux -> libcopilot_runtime.so osx -> libcopilot_runtime.dylib (these are what the Rust cdylib would be called if napi didn't rename it to .node; the .NET host loads it by absolute path, so the name is ours). This avoids shipping a doubly-non-obvious prebuilds//runtime.node tree in .NET output. Emission is conditioned on the source existing, so stdio-only consumers on tarballs without the cdylib are unaffected. FfiRuntimeHost resolves the cdylib relative to the entrypoint, preferring the flat renamed sibling and falling back to the dev dist-cli/prebuilds/-/runtime.node layout (for COPILOT_CLI_PATH overrides). The prebuilds folder uses the napi-rs - convention (win32-x64/darwin-x64/linux-x64), not the .NET RID (win-x64/osx-x64) which only matched on Linux; Client now computes this via GetNapiPrebuildsFolder. Validated: FFI E2E passes against a locally-built SEA copilot binary using both the flat renamed libcopilot_runtime.so and the dev prebuilds fallback; the targets emit copilot + libcopilot_runtime.so in runtimes//native/ and skip the cdylib gracefully when absent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 33 +++++++++++++++--- dotnet/src/FfiRuntimeHost.cs | 38 ++++++++++++++++----- dotnet/src/build/GitHub.Copilot.SDK.targets | 19 +++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 43b94a1773..e9f62d1f17 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -291,7 +291,7 @@ async Task 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(), GetPortableRidOrThrow(), _options.Environment, _logger); + var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), _options.Environment, _logger); _ffiHost = ffiHost; await ffiHost.StartAsync(ct); connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost); @@ -2151,9 +2151,34 @@ private string ResolveCliPathForFfi() + $"environment variable, or ensure the bundled CLI is present (looked in '{searchedPath}')."); } - private static string GetPortableRidOrThrow() => - GetPortableRid() - ?? throw new InvalidOperationException("Could not determine a portable runtime identifier for FFI hosting."); + /// + /// Returns the napi-rs prebuilds folder name for the current host — the + /// <node-platform>-<arch> convention (e.g. win32-x64, + /// darwin-arm64, linux-x64) under which the runtime ships + /// prebuilds/<folder>/runtime.node. This differs from the .NET RID + /// (win-x64/osx-x64) for Windows and macOS. + /// + 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 Args) ResolveCliCommand(string cliPath, IEnumerable args) { diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs index ef7c48c893..7004c91e9e 100644 --- a/dotnet/src/FfiRuntimeHost.cs +++ b/dotnet/src/FfiRuntimeHost.cs @@ -71,26 +71,46 @@ private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictio /// Loads the cdylib next to the given CLI entrypoint and prepares the FFI host. /// The entrypoint is either the packaged single-file CLI binary (e.g. /// runtimes/<rid>/native/copilot) or, for dev, a .js file (e.g. - /// dist-cli/index.js) launched via node. Either way the sibling - /// prebuilds/<rid>/runtime.node cdylib is resolved relative to it. + /// dist-cli/index.js) launched via node. The cdylib is resolved + /// relative to the entrypoint directory, preferring the flat, natural + /// shared-library name the .NET build emits (e.g. libcopilot_runtime.so) + /// and falling back to the dev tarball layout + /// prebuilds/<prebuildsFolder>/runtime.node, where + /// is the napi-rs + /// <node-platform>-<arch> folder name (e.g. win32-x64). /// - public static FfiRuntimeHost Create(string cliEntrypoint, string portableRid, IReadOnlyDictionary? environment, ILogger logger) + public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder, IReadOnlyDictionary? environment, ILogger logger) { var fullEntrypoint = Path.GetFullPath(cliEntrypoint); var distDir = Path.GetDirectoryName(fullEntrypoint) ?? throw new InvalidOperationException($"Could not determine directory for '{cliEntrypoint}'."); - var prebuildsDir = Path.Combine(distDir, "prebuilds", portableRid); - var libraryPath = Path.Combine(prebuildsDir, "runtime.node"); - if (!File.Exists(libraryPath)) - { - throw new InvalidOperationException($"FFI runtime library not found at '{libraryPath}'."); - } + // Bundled .NET layout: flat, natural shared-library name next to the CLI. + var flatLibraryPath = Path.Combine(distDir, GetRuntimeLibraryFileName()); + // Dev/tarball layout: dist-cli/prebuilds/-/runtime.node. + var prebuildsLibraryPath = Path.Combine(distDir, "prebuilds", prebuildsFolder, "runtime.node"); + + var libraryPath = File.Exists(flatLibraryPath) ? flatLibraryPath + : File.Exists(prebuildsLibraryPath) ? prebuildsLibraryPath + : throw new InvalidOperationException( + $"FFI runtime library not found. Looked for '{flatLibraryPath}' and '{prebuildsLibraryPath}'."); PrepareNativeLibrary(libraryPath); return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, logger); } + /// + /// The natural platform shared-library file name for the runtime cdylib, as + /// emitted by the .NET build (the .node file renamed to what the Rust cdylib + /// would be called on this OS). + /// + private static string GetRuntimeLibraryFileName() + { + if (OperatingSystem.IsWindows()) return "copilot_runtime.dll"; + if (OperatingSystem.IsMacOS()) return "libcopilot_runtime.dylib"; + return "libcopilot_runtime.so"; + } + /// /// Starts the in-process runtime: spawns the CLI worker via the Rust host, /// waits for readiness, and opens the FFI JSON-RPC connection. diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index 94b6515ea7..5a5e511811 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -35,6 +35,13 @@ <_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-arm64'">darwin-arm64 <_CopilotBinary Condition="$(_CopilotRid.StartsWith('win-'))">copilot.exe <_CopilotBinary Condition="'$(_CopilotBinary)' == ''">copilot + + <_CopilotRuntimeLib Condition="$(_CopilotRid.StartsWith('win-'))">copilot_runtime.dll + <_CopilotRuntimeLib Condition="$(_CopilotRid.StartsWith('osx-'))">libcopilot_runtime.dylib + <_CopilotRuntimeLib Condition="'$(_CopilotRuntimeLib)' == ''">libcopilot_runtime.so + <_CopilotRuntimeNodePath>$(_CopilotCacheDir)\prebuilds\$(_CopilotPlatform)\runtime.node + + From 396870fe84bdc1c205356fa25bf0062c4fee63ce Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 3 Jul 2026 15:57:49 +0000 Subject: [PATCH 4/5] FFI: zero-copy send path and single-write framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native connection_write copies its bytes synchronously, so the C# side need not own the buffer past the call. Thread frames through as ReadOnlySpan instead of allocating+copying a byte[] per write (LibraryImport marshals the span pointer; the netstandard2.0 delegate pins it). Also coalesce the LSP Content-Length header and JSON body into one pooled buffer so each message is a single write — one native boundary crossing instead of two. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/FfiRuntimeHost.cs | 38 +++++++++++++++++++++++------------- dotnet/src/JsonRpc.cs | 36 +++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs index 7004c91e9e..2e0cc61bcb 100644 --- a/dotnet/src/FfiRuntimeHost.cs +++ b/dotnet/src/FfiRuntimeHost.cs @@ -191,7 +191,14 @@ private static byte[] BuildArgvJson(string cliEntrypoint) return stream.ToArray(); } - private bool SendFrame(byte[] frame) + /// + /// Writes one framed message to the native connection. The bytes are read + /// synchronously by the native side (it copies before returning), so the + /// span does not need to outlive the call — no allocation or copy on our side. + /// + private delegate bool FrameWriter(ReadOnlySpan frame); + + private bool SendFrame(ReadOnlySpan frame) { if (_disposed || _connectionId == 0) { @@ -308,7 +315,7 @@ private uint NativeOpenConnection(uint serverId) private static bool NativeHostShutdown(uint serverId) => HostShutdown(serverId); - private static bool NativeConnectionWrite(uint connectionId, byte[] frame) => ConnectionWrite(connectionId, frame, Len(frame.Length)); + private static bool NativeConnectionWrite(uint connectionId, ReadOnlySpan frame) => ConnectionWrite(connectionId, frame, Len(frame.Length)); private static bool NativeConnectionClose(uint connectionId) => ConnectionClose(connectionId); @@ -357,7 +364,7 @@ private static unsafe partial uint ConnectionOpen( [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_connection_write")] [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] [return: MarshalAs(UnmanagedType.U1)] - private static partial bool ConnectionWrite(uint connectionId, byte[] bytes, nuint bytesLen); + private static partial bool ConnectionWrite(uint connectionId, ReadOnlySpan bytes, nuint bytesLen); [LibraryImport(LibraryName, EntryPoint = "copilot_runtime_connection_close")] [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] @@ -390,7 +397,7 @@ private delegate uint ConnectionOpenDelegate( [UnmanagedFunctionPointer(CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.U1)] - private delegate bool ConnectionWriteDelegate(uint connectionId, byte[] bytes, UIntPtr bytesLen); + private delegate bool ConnectionWriteDelegate(uint connectionId, IntPtr bytes, UIntPtr bytesLen); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.U1)] @@ -462,7 +469,13 @@ private uint NativeOpenConnection(uint serverId) private static bool NativeHostShutdown(uint serverId) => s_hostShutdown!(serverId); - private static bool NativeConnectionWrite(uint connectionId, byte[] frame) => s_connectionWrite!(connectionId, frame, Len(frame.Length)); + private static unsafe bool NativeConnectionWrite(uint connectionId, ReadOnlySpan frame) + { + fixed (byte* ptr = frame) + { + return s_connectionWrite!(connectionId, (IntPtr)ptr, Len(frame.Length)); + } + } private static bool NativeConnectionClose(uint connectionId) => s_connectionClose!(connectionId); @@ -600,26 +613,23 @@ public override void Flush() { } /// A write-only stream that forwards each frame to the native /// connection_write export. /// - private sealed class CallbackSendStream(Func write) : Stream + private sealed class CallbackSendStream(FrameWriter write) : Stream { - public override void Write(byte[] buffer, int offset, int count) - { - var frame = new byte[count]; - Array.Copy(buffer, offset, frame, 0, count); - write(frame); - } + public override void Write(byte[] buffer, int offset, int count) => write(buffer.AsSpan(offset, count)); #if !NETSTANDARD2_0 + public override void Write(ReadOnlySpan buffer) => write(buffer); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - write(buffer.ToArray()); + write(buffer.Span); return ValueTask.CompletedTask; } #endif public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - Write(buffer, offset, count); + write(buffer.AsSpan(offset, count)); return Task.CompletedTask; } diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index edd0534ab8..8cc3a02816 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -206,14 +206,12 @@ public void Dispose() private async Task SendMessageAsync(T message, JsonTypeInfo typeInfo, CancellationToken cancellationToken) { - // "Content-Length: " (16) + max int digits (10) + "\r\n\r\n" (4) - const int MaxHeaderLength = 30; - var json = JsonSerializer.SerializeToUtf8Bytes(message, typeInfo); - var headerBuf = ArrayPool.Shared.Rent(MaxHeaderLength); - bool wrote = Utf8.TryWrite(headerBuf, $"Content-Length: {json.Length}\r\n\r\n", out int headerLen); - Debug.Assert(wrote && headerLen > 0); + // Format the LSP header and body into a single pooled buffer so the framed + // message is written in one call — over the FFI transport that is one native + // boundary crossing per message instead of two. + var frame = BuildFrame(json, out int frameLen); // Cancellation only applies to *waiting* for the write lock. Once we hold the lock // and start writing a framed message, we must finish it — cancelling between the @@ -223,17 +221,37 @@ private async Task SendMessageAsync(T message, JsonTypeInfo typeInfo, Canc await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await _sendStream.WriteAsync(headerBuf.AsMemory(0, headerLen), CancellationToken.None).ConfigureAwait(false); - await _sendStream.WriteAsync(json, CancellationToken.None).ConfigureAwait(false); + await _sendStream.WriteAsync(frame.AsMemory(0, frameLen), CancellationToken.None).ConfigureAwait(false); await _sendStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); } finally { _writeLock.Release(); - ArrayPool.Shared.Return(headerBuf); + ArrayPool.Shared.Return(frame); } } + /// + /// Writes Content-Length: N\r\n\r\n followed by into a + /// single buffer rented from . The caller owns the returned + /// buffer and must return it to the shared pool. + /// + private static byte[] BuildFrame(ReadOnlySpan json, out int frameLen) + { + // "Content-Length: " (16) + max int digits (10) + "\r\n\r\n" (4) + const int MaxHeaderLength = 30; + + Span header = stackalloc byte[MaxHeaderLength]; + bool wrote = Utf8.TryWrite(header, $"Content-Length: {json.Length}\r\n\r\n", out int headerLen); + Debug.Assert(wrote && headerLen > 0); + + frameLen = headerLen + json.Length; + var frame = ArrayPool.Shared.Rent(frameLen); + header.Slice(0, headerLen).CopyTo(frame); + json.CopyTo(frame.AsSpan(headerLen)); + return frame; + } + private async Task ReadLoopAsync(CancellationToken cancellationToken) { var buffer = new byte[256]; From ba941d42038fa5e1b7deddcd8f23ecdadf9a932f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 3 Jul 2026 16:24:03 +0000 Subject: [PATCH 5/5] JsonRpc: write frame header straight into the pooled buffer BuildFrame stackalloc'd a header scratch then copied it into the rented frame. Over-rent by the fixed <=30B header bound instead and write the header directly into the frame, dropping the scratch buffer and header copy. The single JSON copy remains (its length must precede it as Content-Length). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/JsonRpc.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index 8cc3a02816..85fc90ddb5 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -241,14 +241,16 @@ private static byte[] BuildFrame(ReadOnlySpan json, out int frameLen) // "Content-Length: " (16) + max int digits (10) + "\r\n\r\n" (4) const int MaxHeaderLength = 30; - Span header = stackalloc byte[MaxHeaderLength]; - bool wrote = Utf8.TryWrite(header, $"Content-Length: {json.Length}\r\n\r\n", out int headerLen); + // Over-rent by the (fixed, tiny) header bound so the header can be written + // straight into the frame — no scratch buffer or header copy. The JSON is + // already UTF-8, so the only copy is placing it after the header, which is + // unavoidable since Content-Length needs its length up front. + var frame = ArrayPool.Shared.Rent(MaxHeaderLength + json.Length); + bool wrote = Utf8.TryWrite(frame, $"Content-Length: {json.Length}\r\n\r\n", out int headerLen); Debug.Assert(wrote && headerLen > 0); - frameLen = headerLen + json.Length; - var frame = ArrayPool.Shared.Rent(frameLen); - header.Slice(0, headerLen).CopyTo(frame); json.CopyTo(frame.AsSpan(headerLen)); + frameLen = headerLen + json.Length; return frame; }