Add in-process FFI transport for Rust and TypeScript SDKs#1915
Add in-process FFI transport for Rust and TypeScript SDKs#1915SteveSandersonMS wants to merge 41 commits into
Conversation
| id: setup-copilot | ||
|
|
||
| - name: Install Rust toolchain | ||
| uses: dtolnay/rust-toolchain@stable |
| with: | ||
| toolchain: "1.94.0" | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| fn bind<'lib, T>( | ||
| lib: &'lib Library, | ||
| symbol: &[u8], | ||
| library_path: &Path, |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
9587efd to
259911f
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1915 · sonnet46 2M
| * corresponding environment variables on the host process instead. See | ||
| * https://github.com/github/copilot-sdk/issues/1934. | ||
| */ | ||
| forInProcess(): InProcessRuntimeConnection { |
There was a problem hiding this comment.
Cross-SDK consistency note — option validation approach differs from .NET
The .NET SDK (added in #1901) throws ArgumentException at construction time if Environment or Telemetry options are set alongside ForInProcess():
// dotnet/src/Client.cs
if (options.Environment is not null)
throw new ArgumentException("...per-client values. Set the variables on the host process environment instead.");
if (options.Telemetry is not null)
throw new ArgumentException("...Configure telemetry via the host process environment...");TypeScript currently documents the limitation in the @experimental tag but silently ignores env, telemetry, gitHubToken, and baseDirectory at runtime without any error or warning. This means a caller who sets gitHubToken and then switches to in-process transport will see silent auth failures rather than a clear error.
Worth considering whether to match .NET's fail-fast approach (throw during CopilotClient construction when incompatible options are detected) so the SDK surface is consistent across languages. Rust takes a third path: it explicitly builds the env and passes it to host_start via the C ABI, so those options are actually honored there.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1915 · sonnet46 2.4M
| * values. Configure the in-process runtime via the host process environment instead. | ||
| * See https://github.com/github/copilot-sdk/issues/1934. | ||
| */ | ||
| private async startInProcessFfi(): Promise<void> { |
There was a problem hiding this comment.
Cross-SDK consistency note: The Rust SDK's build_ffi_environment (rust/src/lib.rs:1439) explicitly applies per-client options — github_token → COPILOT_SDK_AUTH_TOKEN, telemetry, base_directory, custom env — to the in-process transport. This method silently ignores those same options, inheriting only the host process's ambient environment.\n\nThis creates a cross-SDK behavioral difference: a user who sets gitHubToken: 'ghp_...' will have auth work automatically in the Rust SDK but will need to manually set COPILOT_SDK_AUTH_TOKEN in the process environment for the TypeScript SDK.\n\nFor short-term alignment with .NET's approach, consider adding validation that throws when options that can't be honored are set in combination with forInProcess(), e.g.:\nts\nif (this.options.gitHubToken) {\n throw new Error(\n 'CopilotClientOptions.gitHubToken is not currently supported with the in-process transport. ' +\n 'Set COPILOT_SDK_AUTH_TOKEN on the host process environment instead. ' +\n 'See https://github.com/github/copilot-sdk/issues/1934.'\n );\n}\n\nThis avoids silent misconfiguration until #1934 is resolved.
| }); | ||
| } | ||
|
|
||
| fn build_ffi_environment(options: &ClientOptions) -> Vec<(String, String)> { |
There was a problem hiding this comment.
Cross-SDK consistency note: This Rust build_ffi_environment correctly applies per-client options (github_token, telemetry, base_directory, custom env) to the in-process transport, which is more user-friendly than .NET's approach (which rejects Environment/Telemetry with ArgumentException and silently ignores GitHubToken).\n\nHowever, the TypeScript SDK in this same PR takes a third approach: it silently ignores all per-client options for in-process transport (tracked in #1934). It would be worth adding a comment here or in the Rust client docs noting that the Rust in-process transport honors per-client options, since this is a divergence from the TypeScript behavior in this PR.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1915 · sonnet46 2M
Comments that could not be inline-anchored
nodejs/src/client.ts:639
Cross-SDK consistency: missing eager validation for inprocess + incompatible options
The .NET SDK (reference implementation since #1901) throws an ArgumentException in the constructor when Environment or Telemetry options are set alongside InProcessRuntimeConnection — see Client.cs ValidateEnvironmentOptions(). The new TypeScript implementation only documents the limitation in JSDoc but silently ignores those options, which can be confusing.
Suggest adding a "fail fast" chec…
rust/src/lib.rs:1012
Cross-SDK consistency: missing eager validation for Transport::InProcess + incompatible options
The .NET SDK (reference since #1901) rejects incompatible options early with ArgumentException. The TypeScript implementation (also added in this PR) has the same gap. For Rust, suggest adding a validation block after the External checks, mirroring the pattern here:
if matches!(options.transport, Transport::InProcess) {
if options.github_token.is_some() {
return Err(Er…
</details>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Isolates the in-process FFI hang hypothesis with no SDK, runtime cdylib, or JSON-RPC: a tiny C shared library (compiled on the fly with cc/clang) spawns a background thread that invokes a koffi-registered callback after the main event loop goes idle (only a keep-alive timer), mirroring how the runtime's worker-reader thread invokes our outbound callback while the SDK awaits a response. Runs on ubuntu + macOS via a dedicated fast CI job to get a clean cross-platform signal. To be removed once the hang is fixed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Trace analysis showed inbound frame delivery stops mid-stream on macOS (a streaming turn's events flow then halt), not just on idle bare RPCs. The async (libuv-threadpool) write variant competes with koffi's blocking inbound callback relay, so revert connection_write to a synchronous call matching the .NET host and drop the async broker pump. Also add a repro scenario that blasts a rapid burst of background-thread callbacks including a 23638-byte payload (the size that preceded the stall) to test whether koffi drops frames under burst on macOS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the one untested koffi interaction matching real in-process usage: the inbound callback (blocking the reader thread via napi_tsfn_blocking) synchronously re-enters native (like connection_write from inside feedInbound). If this passes on macOS, koffi is fully exonerated and the stall is runtime/worker-side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
koffi 3.0 rewrote call preparation/execution ("vastly improved performance") and
distributes prebuilt binaries as @koromix/koffi-<platform> optionalDependency
subpackages (so `npm ci --ignore-scripts` still resolves the native binary).
Testing whether the newer callback/threading machinery fixes the macOS/Windows
in-process RPC stalls. Our koffi API surface (load/func/pointer/proto/register/
unregister/decode/array) is unchanged across the major bump.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the COPILOT_FFI_TRACE frame-level instrumentation from FfiRuntimeHost (writes/inbound/heartbeat and the log_dropped_count binding) now that the FFI transport is proven to deliver every frame. Restore the plain 1s keep-alive and synchronous writes. Add COPILOT_EVENT_TRACE session-event tracing at the client's session.event / session.lifecycle notification handlers, logging each dispatched event's type. This pinpoints whether the in-process macOS stall is a missing session.idle (runtime never emits it) versus an emitted-but-not-dispatched event, for the approve-all-short-circuit + shell-tool turn that wedges sendAndWait. Enabled on the Node inprocess CI cell. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Log the full sessionId (not truncated to 8 chars, which was ambiguous across
sessions sharing a prefix) and add a trace line inside sendAndWait's event
listener, to conclusively determine whether the in-process macOS hang is caused
by session.idle events arriving for an unregistered session ("no-session" drop)
vs sendAndWait's own listener not receiving them.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mac hang Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…esis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…st (match .NET) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…expose bug) Make in-process connection_write async so the JS main thread is never blocked in a synchronous write while koffi holds the cdylib reader thread in a blocking inbound callback (bidirectional deadlock). Reverts the noresult test's abort so the failing disconnect-while-pending pattern is exercised against the transport fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…estId Instrument feedInbound entry/exit and a keepalive heartbeat, plus log the parked requestId when a no-result permission handler skips the reply, to determine at the macOS/Windows in-process wedge whether koffi stops invoking the inbound callback (worker/cdylib side) or invokes it but stalls inside (JS side). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…quest causation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause of the macOS/Windows-only in-process stall: koffi services its threadsafe-function broker (which marshals foreign-thread outbound callbacks to the JS main thread) ONLY while a koffi async FFI call is in flight; a plain JS timer does not pump it. During an idle stretch mid-turn (worker awaiting a model HTTP response, no client->server writes), the next outbound frame — the model response and everything after — sits undelivered until the next koffi call, wedging the session for 30s. Linux's libuv services the broker differently, so it only reproduced off-Linux. Keep exactly one cheap async FFI call (log_dropped_count) continuously in flight so the broker always pumps; the in-flight libuv request also keeps the event loop alive, replacing the keep-alive timer. Restores the no-result permission test to its real disconnect-while-pending pattern (no longer needs to reply to pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…3-core) Test whether the in-process wedge is CPU-count dependent (tokio in the runtime cdylib uses Builder::new_multi_thread() = one worker per core; macos-latest is 3-core M1). macos-latest-xlarge is the same Apple-Silicon arch with 6 cores, so a pass would isolate core count from OS/arch as the trigger. Only the runner changes vs the last failing run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rker-count theory Reverts mac runner to macos-latest. If Linux under taskset -c 0 (=> tokio default worker_threads=1 via available_parallelism, which honors CPU affinity on Linux) reproduces the in-process wedge, the trigger is tokio worker-thread count, not OS/arch. Linux runs are fast and reliable, making this a cleaner test than swapping mac runner sizes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-count threshold Linux on 1 CPU reproduced the in-process wedge (tokio worker_threads=1), confirming the trigger is tokio worker-thread count. Test 2 CPUs to locate the threshold at which it stops wedging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tore full in-proc CI 1.0.70-0 is expected to fix the in-process runtime worker-thread starvation that wedged low-core (macOS/Windows) hosts. Locally the full permissions E2E file now passes pinned to a single CPU (taskset -c 0) with the real no-result+disconnect pattern (previously 4 failures). Restore the inprocess CI cell to run the full suite on all OSes (removed the single-file/taskset/tracing diagnostic scaffolding) so macOS/Windows in-proc get a real validation against 1.0.70-0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e.db forceStop() skips the runtime.shutdown() RPC, so the in-process runtime's SQLite session-store.db handle is never closed. On Windows the locked file fails temp-dir cleanup (EBUSY), exhausting rmDir's 30x1s retry budget and tripping the 30s afterAll hook timeout. Graceful stop() closes the DB. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes a Windows STATUS_ACCESS_VIOLATION (0xc0000005) crash in the Rust e2e binary: FfiShared::Drop unloaded the cdylib (FreeLibrary/dlclose) when a connection closed, racing the runtime's still-live worker threads — a late worker-thread callback into the unmapped module faults on Windows. The crash hit both Rust transport cells because the in-process smoke test runs regardless of transport. Load each cdylib once into a process-global cache and leak it (Box::leak) so its code stays mapped for the process lifetime, matching the Node host (module-global load, never unloaded) and the runtime's never-shutdown process-global tokio runtime. close() still shuts the host down; only the library unload is removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The in-process FFI host never set the runtime worker's current directory, so it inherited the SDK process's cwd instead of the client's working_directory. Unlike the stdio/tcp transports (build_command sets .current_dir(working_directory)), workspace-relative file operations therefore resolved against the wrong directory: the model's tool calls used a relative path (e.g. 'order.txt') that the runtime rejected as 'not absolute', diverging from the recorded replay snapshot and surfacing as a proxy 500 / send_and_wait failure — but only in-process. Mirror the Node in-process host: switch cwd to working_directory for the duration of the blocking host_start (which spawns the worker), then restore it. Confirmed locally: the affected event_fidelity + hooks tests pass in-process, and the test already passed over stdio. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Merge std::sync imports onto one line per .rustfmt.nightly.toml (imports_granularity=Module); fixes the ubuntu 'cargo fmt --check (nightly)' step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… user In-process, the SDK github_token (lowered to --auth-token-env COPILOT_SDK_AUTH_TOKEN for the spawned child) is not passed to the worker, so host-side auth resolves from GH_TOKEN/GITHUB_TOKEN. The InProcessEnvGuard set these to a placeholder the replay mock never registered, so auth resolved as unauthenticated (client::should_get_ authenticated_status / should_list_models_when_authenticated) or hit real GitHub. Use DEFAULT_TEST_TOKEN — the token set_default_copilot_user registers as the authenticated Copilot user — so in-process auth matches the mock. Fixes the two client auth tests and callback_token_is_applied_as_authorization_header in-process. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The runtime registers the LLM inference provider per connection and, by design,
never releases the slot on disconnect (runtime shared_api/llm_inference.rs). Over
the in-process transport every client shares this process's runtime, so a second
provider-registering client is refused ('Another client is already the LLM
inference provider') — deterministically failing reacquires_a_fresh_token_for_each_
request and dispatches_token_acquisition_per_provider when they run after the first
BYOK test. stdio spawns a separate child per test, so all three run there.
The BYOK bearer-token path over the in-process transport is validated by
callback_token_is_applied_as_authorization_header (still runs in-process); the
per-request / per-provider dispatch these two exercise is transport-agnostic and
fully covered over stdio. Guard them with skip_inprocess, matching the branch's
pattern for genuine in-process runtime limitations.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… gate in-process Two in-process-only E2E fixes: 1. BYOK provider poisoning (the big cascade): the runtime's LLM inference provider slot is process-global and is never released when the registering connection disconnects (runtime shared_api/llm_inference.rs). Over the in-process transport all clients share one runtime, so once callback_token_is_applied registers a BYOK provider and its client stops, the dangling registration routes every later model-inference request to the dead connection and hangs it (180s timeouts). This is what wedged should_list_models_when_authenticated and cascaded into event_fidelity/hooks/mode_*/etc. Skip all three BYOK provider tests in-process (they run over stdio, one child process per test); locally this clears the cascade. 2. provider_endpoint: the test opts into session.provider.getEndpoint via per-client options.env (COPILOT_ALLOW_GET_PROVIDER_ENDPOINT), which the in-process transport does not pass to the shared worker. Apply that process-global runtime gate via the InProcessEnvGuard (inert for tests that don't call the API). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
6b989d4 to
b1576e0
Compare
Cross-SDK Consistency ReviewThis PR ports the in-process FFI transport from .NET (#1901) to TypeScript and Rust. Here's how the implementations compare. ✅ API naming — consistent across the three SDKs
|
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #1915 · sonnet46 2M
| CopilotClient.resolveDefaultConnection(options); | ||
|
|
||
| if ( | ||
| conn.kind === "uri" && |
There was a problem hiding this comment.
Cross-SDK consistency gap: missing validation for per-client options with inprocess transport
The .NET SDK (added in #1901) throws ArgumentException at construction time when Environment or Telemetry options are set alongside RuntimeConnection.ForInProcess() (Client.cs ValidateEnvironmentOptions). This gives callers an early, actionable error rather than silently ignoring the configuration.
The TypeScript SDK documents the limitation well in the JSDoc, but doesn't fail fast. A caller who passes gitHubToken, telemetry, baseDirectory, or env with { kind: "inprocess" } will see those values silently dropped.
To mirror .NET, consider adding a guard in the constructor alongside the existing uri validation above:
if (conn.kind === "inprocess") {
if (options.gitHubToken !== undefined) {
throw new Error(
"gitHubToken is not supported with RuntimeConnection.forInProcess(): the in-process " +
"transport inherits the host process environment. Set COPILOT_SDK_AUTH_TOKEN on the " +
"host process instead."
);
}
if (options.telemetry !== undefined) {
throw new Error(
"telemetry is not supported with RuntimeConnection.forInProcess(): telemetry config " +
"is lowered to environment variables that cannot vary per-client in-process."
);
}
// optionally: env, baseDirectory
}This is especially important for gitHubToken — a caller who sets it expecting authentication to work will get a confusing silent failure without this guard.
| } | ||
| // Auth options only make sense when the SDK spawns the CLI; with an | ||
| // external server, the server manages its own auth. | ||
| if matches!(options.transport, Transport::External { .. }) { |
There was a problem hiding this comment.
Cross-SDK consistency gap: missing validation for per-client options with Transport::InProcess
The .NET SDK throws ArgumentException when Environment or Telemetry are passed with RuntimeConnection.ForInProcess() (see ValidateEnvironmentOptions in Client.cs). Rust documents the limitation in InProcess's docstring but doesn't return an early Err.
Consider adding a validation block here analogous to the Transport::External check just above:
if matches!(options.transport, Transport::InProcess) {
if options.github_token.is_some() {
return Err(Error::with_message(
ErrorKind::InvalidConfig,
"github_token cannot be used with Transport::InProcess: the in-process \
runtime inherits the host process environment. Set COPILOT_SDK_AUTH_TOKEN \
on the host process instead.",
));
}
if options.telemetry.is_some() {
return Err(Error::with_message(
ErrorKind::InvalidConfig,
"telemetry cannot be used with Transport::InProcess: telemetry config is \
lowered to environment variables that cannot vary per-client in-process.",
));
}
// optionally: env, env_remove, base_directory
}Without this, callers who set github_token expecting authentication will get a silent failure rather than an actionable error message.
Summary
Ports the C# in-process FFI hosting transport (merged in #1901) to the Rust and TypeScript/Node SDKs, mirroring the .NET
RuntimeConnection.ForInProcess()API. Both SDKs load the runtime cdylib and speak JSON-RPC over its C ABI (copilot_runtime_host_start/connection_open/connection_write/connection_close/host_shutdown) instead of spawning a stdio/TCP child process. Framing is unchanged — it's a transport swap over the existing LSPContent-LengthJSON-RPC codec.Rust
Transport::InProcessvariant;COPILOT_SDK_DEFAULT_CONNECTION(inprocess/stdio/unset) selects the default, mirroring .NET'sResolveDefaultConnection.rust/src/ffi.rs:libloadingC-ABI bindings,AsyncRead/AsyncWritebridge between the outbound callback andconnection_write, ordered teardown.build.rs/embeddedcli.rs: best-effort extract + rename ofprebuilds/<platform>/runtime.node→libcopilot_runtime.{so,dylib}/copilot_runtime.dllnext to the CLI (mirrors the .NET.targets).prebuilds/<node-platform>-<arch>/runtime.node.test-inprocessCI job.TypeScript
RuntimeConnection.forInProcess()+InProcessRuntimeConnection.nodejs/src/ffiRuntimeHost.ts:koffiloadsruntime.nodedirectly from the@github/copilot-<platform>package (no rename); asynchost_start, registered outbound callback bridged tovscode-jsonrpcstreams.COPILOT_SDK_DEFAULT_CONNECTIONdefault + in-process E2E test +transportCI matrix cell.CI
Both workflows gain an
inprocesstransport cell that setsCOPILOT_SDK_DEFAULT_CONNECTION=inprocess, matchingdotnet-sdk-tests.yml.Status / known gaps (draft — letting CI surface these)
client.e2e(incl. auth + list-models) passes fully in-process.mcp_and_agents): the in-process host addon runs in the SDK's own process and readsprocess.env, so the harness-supplied proxy-redirect env (passed asoptions.env) doesn't reach MCP-related host-side paths the way it does for a spawned stdio child. Needs follow-up on env propagation for the in-process worker/host.COPILOT_CLI_PATH) path is untested locally because the GitHub-release CLI tarball doesn't yet shipruntime.node; CI uses the npm platform package viaCOPILOT_CLI_PATH.🤖 Generated with in-process FFI port; opening as draft to let CI run the full matrix.