Skip to content

dotnet: in-process FFI runtime hosting (InProcess transport)#1901

Open
SteveSandersonMS wants to merge 8 commits into
mainfrom
stevesa/ffi-inproc-host
Open

dotnet: in-process FFI runtime hosting (InProcess transport)#1901
SteveSandersonMS wants to merge 8 commits into
mainfrom
stevesa/ffi-inproc-host

Conversation

@SteveSandersonMS

@SteveSandersonMS SteveSandersonMS commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

In-process FFI runtime hosting for the .NET SDK

Adds a new InProcess transport to the .NET SDK. Instead of spawning the CLI as a subprocess and talking JSON-RPC over stdio/TCP, the SDK loads the runtime's native shared library in-process and drives JSON-RPC over its C ABI. The native host spawns the residual Node worker itself; the C# side only pumps opaque JSON-RPC blobs across the FFI boundary.

What's here

  • FfiRuntimeHost.cs — shared host body (native library resolution, host_start/connection_open handshake, duplex Channel-backed streams bridging the native outbound callback to the SDK's JSON-RPC framing) with two interop backends:

    • net8.0 / net10.0 — source-generated [LibraryImport] P/Invoke with a DllImportResolver mapping the logical library name to the absolute path of the native lib, 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).
  • Types.csInProcessRuntimeConnection + RuntimeConnection.ForInProcess(path, args).

  • Client.cs — selects the FFI transport for InProcessRuntimeConnection (selected explicitly, or via the COPILOT_SDK_DEFAULT_CONNECTION=inprocess default-transport override); resolves the entrypoint from COPILOT_CLI_PATH and falls back to the bundled CLI the same way stdio discovers it.

  • MSBuild targets — emit the native shared library next to the bundled CLI as a natural, platform-obvious name in runtimes/<rid>/native/:

    • Windows → copilot_runtime.dll
    • Linux → libcopilot_runtime.so
    • macOS → libcopilot_runtime.dylib

    The .NET host loads it by absolute path, so the filename is ours. Emission is conditioned on the source existing, so stdio-only consumers are unaffected.

  • ClientE2ETestsShould_Start_And_Connect_Over_InProcess_Ffi.

Status — draft

This depends on native in-process-host support that ships in a future @github/copilot CLI release. It should not be merged until the pinned CLI version is bumped to a build that ships the runtime shared library and the in-process host capability. Kept as a draft until then.

The default stdio/TCP transports are unchanged; InProcess is strictly opt-in.

SteveSandersonMS and others added 3 commits July 3, 2026 11:42
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>
…e-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/<rid>/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>
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/<npm-platform>/runtime.node to a flat,
natural shared-library name in runtimes/<rid>/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/<node-platform>/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/<node-platform>-<arch>/runtime.node layout (for
COPILOT_CLI_PATH overrides). The prebuilds folder uses the napi-rs
<node-platform>-<arch> 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/<rid>/native/
and skip the cdylib gracefully when absent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment thread dotnet/src/Client.cs
if (ctx.FfiHost is { } ffiHost)
{
try { ffiHost.Dispose(); }
catch (Exception ex) { AddCleanupError(errors, ex, _logger); }
?? 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());
// Bundled .NET layout: flat, natural shared-library name next to the CLI.
var flatLibraryPath = Path.Combine(distDir, GetRuntimeLibraryFileName());
// Dev/tarball layout: dist-cli/prebuilds/<node-platform>-<arch>/runtime.node.
var prebuildsLibraryPath = Path.Combine(distDir, "prebuilds", prebuildsFolder, "runtime.node");
Comment on lines +227 to +230
catch (Exception ex)
{
_logger.LogDebug(ex, "FfiRuntimeHost: connection_close failed");
}
Comment on lines +240 to +243
catch (Exception ex)
{
_logger.LogDebug(ex, "FfiRuntimeHost: host_shutdown failed");
}
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);
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);
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);
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>
@github-actions

This comment has been minimized.

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>
@github-actions

This comment has been minimized.

@SteveSandersonMS SteveSandersonMS marked this pull request as ready for review July 6, 2026 07:47
@SteveSandersonMS SteveSandersonMS requested a review from a team as a code owner July 6, 2026 07:47
Copilot AI review requested due to automatic review settings July 6, 2026 07:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an opt-in InProcess transport to the .NET SDK that hosts the Copilot runtime in-process via a native shared library and drives JSON-RPC across an FFI (C ABI) boundary, instead of spawning the CLI and using stdio/TCP.

Changes:

  • Introduces InProcessRuntimeConnection and default-connection selection via COPILOT_SDK_DEFAULT_CONNECTION.
  • Adds FfiRuntimeHost to load the native library, start the embedded host, and bridge JSON-RPC I/O via callback-backed streams.
  • Updates JSON-RPC framing to write header+body in a single write (important for FFI boundary crossings) and adds an E2E test.
Show a summary per file
File Description
dotnet/test/E2E/ClientE2ETests.cs Adds an E2E test for starting/connecting over the new in-process FFI transport.
dotnet/src/Types.cs Adds InProcessRuntimeConnection and RuntimeConnection.ForInProcess() API surface and docs.
dotnet/src/JsonRpc.cs Changes outgoing message framing to build a single buffer for header+JSON body.
dotnet/src/GitHub.Copilot.SDK.csproj Enables unsafe blocks (needed for function pointers / fixed buffers in FFI host).
dotnet/src/FfiRuntimeHost.cs New in-process FFI host implementation (library resolution, start/open/close, callback streams).
dotnet/src/Client.cs Wires InProcessRuntimeConnection into client startup/cleanup; adds default-connection env var logic and FFI CLI path resolution.
dotnet/src/build/GitHub.Copilot.SDK.targets Copies/renames the runtime cdylib next to the bundled CLI in runtimes/<rid>/native/ when present.

Review details

  • Files reviewed: 7/7 changed files
  • Comments generated: 11
  • Review effort level: Low

Comment thread dotnet/test/E2E/ClientE2ETests.cs Outdated
Comment thread dotnet/src/JsonRpc.cs
Comment thread dotnet/src/FfiRuntimeHost.cs Outdated
Comment thread dotnet/src/FfiRuntimeHost.cs
Comment thread dotnet/src/FfiRuntimeHost.cs
Comment thread dotnet/src/Types.cs
Comment thread dotnet/src/Types.cs
Comment thread dotnet/src/Client.cs
Comment thread dotnet/src/Client.cs
Comment thread dotnet/src/FfiRuntimeHost.cs Outdated
SteveSandersonMS and others added 2 commits July 6, 2026 08:31
Add a transport matrix axis (default + inprocess) to the .NET SDK test
workflow so win/macos/linux each run the suite with
COPILOT_SDK_DEFAULT_CONNECTION=inprocess as well as the existing default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Set the env var via a guarded step writing to GITHUB_ENV so the default
transport cells leave it entirely unset rather than set to empty.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

- Fix XML docs claiming InProcess is .NET 8+ only; it works on
  netstandard2.0 too via the fallback native loader.
- Correct the stale bundled-cdylib layout comment in Client to match
  FfiRuntimeHost.Create's flat-name-first resolution.
- Run the inproc FFI E2E test unconditionally (fail hard) instead of
  silently returning when COPILOT_CLI_PATH is unset.
- Throw when the native connection_write reports failure instead of
  silently succeeding.
- Retry in CallbackReceiveStream rather than reporting a spurious EOF
  when a signalled read loses the race.
- Fail fast when a second, different FFI library path is requested in
  the same process (both interop paths).
- Handle Utf8.TryWrite failure in JsonRpc.BuildFrame explicitly and
  return the rented buffer instead of a Debug-only assert.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Cross-SDK Consistency Review ✅

All changed files are in dotnet/ (plus a CI workflow update). The PR adds a new InProcessRuntimeConnection transport — it does not modify any existing public APIs.

Transport inventory across SDKs:

SDK Transports
Node.js StdioRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection
Python StdioRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection
Go StdioConnection, TCPConnection, URIConnection
.NET (this PR) + InProcessRuntimeConnection (new)
Java flat options: cliPath / cliUrl
Rust Transport::Stdio, Transport::Tcp, Transport::External

Assessment: No cross-SDK consistency issues. The in-process FFI transport is a .NET-specific capability built on [LibraryImport] / NativeLibrary / [UnmanagedCallersOnly] (with a netstandard2.0 dlopen/LoadLibrary fallback). Equivalent FFI hosting in other SDKs would require entirely different platform mechanisms (cgo, ctypes, JNI, node-addon-api, direct Rust linking) and is naturally gated on the same future CLI native-library support this PR depends on. The feature is strictly opt-in and the existing three transports are unchanged across all SDKs.

Generated by SDK Consistency Review Agent for issue #1901 · sonnet46 1.5M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants