diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 6041fe2391..e9f62d1f17 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 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; @@ -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,55 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) 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//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}')."); + } + + /// + /// 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) { var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase); @@ -2089,7 +2192,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 +2202,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 +2273,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 +2476,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 +2485,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..2e0cc61bcb --- /dev/null +++ b/dotnet/src/FfiRuntimeHost.cs @@ -0,0 +1,647 @@ +/*--------------------------------------------------------------------------------------------- + * 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.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 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 +/// 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 _cliEntrypoint; + private readonly string _libraryPath; + 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 cliEntrypoint, IReadOnlyDictionary? environment, ILogger logger) + { + _libraryPath = libraryPath; + _cliEntrypoint = cliEntrypoint; + _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 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. 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 prebuildsFolder, IReadOnlyDictionary? environment, ILogger logger) + { + var fullEntrypoint = Path.GetFullPath(cliEntrypoint); + var distDir = Path.GetDirectoryName(fullEntrypoint) + ?? throw new InvalidOperationException($"Could not determine directory for '{cliEntrypoint}'."); + + // 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. + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + // 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(_cliEntrypoint); + var envJson = BuildEnvJson(_environment); + + _serverId = NativeHostStart(argvJson, envJson); + if (_serverId == 0) + { + throw new InvalidOperationException( + $"copilot_runtime_host_start failed (library '{_libraryPath}', entrypoint '{_cliEntrypoint}')."); + } + + _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 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(); + if (isJsFile) + { + writer.WriteStringValue("node"); + } + writer.WriteStringValue(cliEntrypoint); + 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(); + } + + /// + /// 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) + { + 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[]? env) => + HostStart(argvJson, Len(argvJson.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, ReadOnlySpan 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[]? 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, ReadOnlySpan 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[]? 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, IntPtr 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[]? env) => + s_hostStart!(argvJson, Len(argvJson.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 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); + + 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(FrameWriter write) : Stream + { + 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.Span); + return ValueTask.CompletedTask; + } +#endif + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + write(buffer.AsSpan(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/JsonRpc.cs b/dotnet/src/JsonRpc.cs index edd0534ab8..85fc90ddb5 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,39 @@ 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; + + // 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); + + json.CopyTo(frame.AsSpan(headerLen)); + frameLen = headerLen + json.Length; + return frame; + } + private async Task ReadLoopAsync(CancellationToken cancellationToken) { var buffer = new byte[256]; 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/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 + + 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