diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 8880cadfa..168e3bcf8 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -30,8 +30,35 @@ permissions: contents: read jobs: + # TEMP: standalone koffi foreign-thread callback repro (no SDK/runtime). Fast + # signal on whether koffi delivers a background-thread callback to JS while the + # main loop is idle, across all three OSes. Remove once the in-process hang is fixed. + koffi-callback-repro: + name: "koffi callback repro (${{ matrix.os }})" + if: github.event.repository.fork == false + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: "npm" + cache-dependency-path: "./nodejs/package-lock.json" + node-version: 22 + - name: Install dependencies + run: npm ci --ignore-scripts + - name: Run koffi callback repro + run: npx vitest run test/e2e/_koffi_callback_repro.e2e.test.ts + test: - name: "Node.js SDK Tests" + name: "Node.js SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -39,6 +66,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + transport: ["default", "inprocess"] runs-on: ${{ matrix.os }} defaults: run: @@ -75,7 +103,36 @@ jobs: if: runner.os == 'Windows' run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Select inprocess transport + if: matrix.transport == 'inprocess' + run: | + echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" + echo "COPILOT_EVENT_TRACE=1" >> "$GITHUB_ENV" + echo "COPILOT_FFI_TRACE=1" >> "$GITHUB_ENV" + # TEMP experiment: run the FULL permissions file with event+RPC+callback-liveness + # tracing over in-process to determine whether koffi STOPS invoking the inbound + # callback at the wedge (CB-ENTER count stops -> worker/cdylib side) or invokes + # it but stalls inside (CB-ENTER without CB-EXIT -> JS side), while TICKs prove + # the event loop is alive. + echo "TEST_TARGET=test/e2e/permissions.e2e.test.ts" >> "$GITHUB_ENV" + echo 'TEST_NAME_FILTER=' >> "$GITHUB_ENV" + - name: Run Node.js SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} - run: npm test + run: | + # TEMP experiment: on Linux inprocess, pin the whole process tree to a single + # CPU via taskset so tokio's default worker count (available_parallelism, which + # respects CPU affinity on Linux) is 1. If Linux then reproduces the mac/win + # in-process wedge, the trigger is tokio worker-thread count, not OS/arch. + RUNNER="" + if [ "$RUNNER_OS" = "Linux" ] && [ "$COPILOT_SDK_DEFAULT_CONNECTION" = "inprocess" ]; then + nproc || true + RUNNER="taskset -c 0,1" + echo "Pinning to 2 CPUs via: $RUNNER" + fi + if [ -n "$TEST_NAME_FILTER" ]; then + $RUNNER npx vitest run "$TEST_TARGET" -t "$TEST_NAME_FILTER" + else + $RUNNER npx vitest run $TEST_TARGET + fi diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index f75a5d6a2..e4f49d9e9 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -29,7 +29,7 @@ permissions: jobs: test: - name: "Rust SDK Tests" + name: "Rust SDK Tests (${{ matrix.os }}, default)" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -129,6 +129,84 @@ jobs: # The dedicated `bundle` job below exercises the embed pipeline. run: cargo test --no-default-features --features test-support -- --test-threads=4 --nocapture + # Exercises the in-process FFI transport (`Transport::InProcess`, the Rust + # analogue of the .NET `RuntimeConnection.ForInProcess()`), mirroring the + # `inprocess` transport cell in dotnet-sdk-tests.yml. Sets + # COPILOT_SDK_DEFAULT_CONNECTION=inprocess so the client hosts the runtime + # cdylib in-process instead of spawning a stdio child, then runs the whole + # E2E suite over the in-process transport. The suite runs serially in-process + # (the harness forces concurrency to 1) because it mirrors each test's + # environment onto the shared process environment the in-process worker inherits. + # Runs the whole E2E suite over the in-process transport on all three OSes, + # matching the Node in-process matrix. + test-inprocess: + name: "Rust SDK Tests (${{ matrix.os }}, inprocess)" + if: github.event.repository.fork == false + env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + working-directory: ./rust + steps: + - uses: actions/checkout@v6.0.2 + + - uses: ./.github/actions/setup-copilot + id: setup-copilot + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.94.0" + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "rust" + prefix-key: v1-rust-no-bin + cache-bin: false + + - name: Read pinned @github/copilot CLI version + id: cli-version + working-directory: ./nodejs + run: | + version=$(node -p "require('./package-lock.json').packages['node_modules/@github/copilot'].version") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Pinned CLI version: $version" + + - name: Cache bundled CLI tarball + uses: actions/cache@v4 + with: + path: ./rust/.bundled-cli-cache + key: bundled-cli-${{ matrix.os }}-${{ steps.cli-version.outputs.version }} + + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + + - name: Select in-process transport + run: echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" + + - name: cargo test (in-process transport, full E2E suite) + timeout-minutes: 60 + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} + BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache + # The harness forces serial execution in-process (both the async semaphore and + # libtest via --test-threads=1) because it mirrors each test's environment onto + # the shared process environment, so RUST_E2E_CONCURRENCY is not set here. + run: cargo test --no-default-features --features test-support --test e2e -- --test-threads=1 --nocapture + # Validates the bundled-CLI build path on all three supported # platforms. While the regular `cargo test` job above also exercises # build.rs (bundling is on by default now), this matrix job is the @@ -136,7 +214,7 @@ jobs: # extract / embed pipeline. Catches regressions before they ship to # crates.io and before bundling consumers hit them downstream. bundle: - name: "Rust SDK Bundled CLI Build" + name: "Rust SDK Bundled CLI Build (${{ matrix.os }})" if: github.event.repository.fork == false env: CARGO_TERM_COLOR: always diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 0dd5e2a24..7d53436e9 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@github/copilot": "^1.0.69-3", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -921,6 +922,230 @@ "dev": true, "license": "MIT" }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.0.tgz", + "integrity": "sha512-VEt5r3fXTfbejr83PnuOP0H7s9Zmazcs+lofu96DOcRkistlMsn59wYyWiKpyAjs9PCgm0Ykh62ChZ3CGMmIOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.0.tgz", + "integrity": "sha512-n/tVRB9xIzdXT5H3zZt8ueThgWTSDL+yU7PWnU8wbZPBSawP/otx3swQyd6nMOqj1bmHgSHopiKSBXRS9pllmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.0.tgz", + "integrity": "sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.0.tgz", + "integrity": "sha512-Vm7Uc97ru6RTSVmae2zCZZQeaizqVZ8WoU4+gG4H03Qe+WOj7kbKt/MxT7VBzdbPYIU5ZJeG/ZED1YlZyab6eQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.0.tgz", + "integrity": "sha512-N+VuVWjoiYPy1Go5mRadZ3B6RM5Qz+eCLhj2LXrMlefbUJ+O4gg7teCUGvPGfBEHDgmSN4yYUrfQmdJC10vOYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.0.tgz", + "integrity": "sha512-Wx5iOkeALe2ympLdiYwRpIg5qUkyQIv8N2foZ9rRker0uE7ZtXew2RRkbEgMir4b0yDYR1zyXd6B62GUzLtZ/g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.0.tgz", + "integrity": "sha512-1DjYm1QehXU0dgn0uE+FGYOb3Of7GiTMqLS+ZI2gbl1b+h76sz4LRBvDVrQyAmSMVVU8/7696S21YgE/iBhBVg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.0.tgz", + "integrity": "sha512-NOa0LdyltdESz3oeTqUH6MErHVoJOHoeXIsEp6xIMTUh4eKXEtlDQeoK6EYqo0DnBt83Xud95qLvi4Aw12pG4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.0.tgz", + "integrity": "sha512-Ye6kiXZCGxGtAIXSly6XuOP5tJZNYOZ2eVg33k1MilKrzimAy9Mpw4d6e9+Sfsc1jesgeNYs1sb5iaI8HS3ncA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.0.tgz", + "integrity": "sha512-3yQTOkQrMna4VX+yeyfYImBjLlGrItMpsWyfaW1uSiz/A6GRydqdwYH7DWnp4Z+RSGYZpsewkf7byMc8pOOQKA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.0.tgz", + "integrity": "sha512-/cDoFHb9yx4+yoT3GUpnKnfi3W2drG+/Ewo0TTZaQHb4PsxnYYyT6V8+t4cL5XXbQcTTcOsZxpmBRrn0NBa3dA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.0.tgz", + "integrity": "sha512-CoQdqgnKvWgTXXZlUst8cBRQEov7QsxlTN2WAsu9wez01Xe6gEcH/zYePANualzzCbnaELfe5P0rA80QkoDuPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.0.tgz", + "integrity": "sha512-WjrA+DEkpy0xEHu48+NSOboHhTnzkIfsFuq3d/WrSs+T9WflWRng3jC7mdJxmR4eHb6i6BqjW3k/U0mNUTjFPA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.0.tgz", + "integrity": "sha512-tnK5+IkzQBauQAQSzuyjso8OOIQRlaTZS39xIWpfqVYDLVDIuLDQk/WwHcOrR5yxlDrZq9ygiebBTOfcJFia7w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2592,6 +2817,32 @@ "json-buffer": "3.0.1" } }, + "node_modules/koffi": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.0.tgz", + "integrity": "sha512-0mCvdjTJBXioiaKNz0vajAEdWtfM5qyhVXSq+wQrrU3odzNvl/J7Cqna79QpNo9mfoKpQgGsyFFDRtDACCwGrQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.0", + "@koromix/koffi-darwin-x64": "3.1.0", + "@koromix/koffi-freebsd-arm64": "3.1.0", + "@koromix/koffi-freebsd-ia32": "3.1.0", + "@koromix/koffi-freebsd-x64": "3.1.0", + "@koromix/koffi-linux-arm64": "3.1.0", + "@koromix/koffi-linux-ia32": "3.1.0", + "@koromix/koffi-linux-loong64": "3.1.0", + "@koromix/koffi-linux-riscv64": "3.1.0", + "@koromix/koffi-linux-x64": "3.1.0", + "@koromix/koffi-openbsd-ia32": "3.1.0", + "@koromix/koffi-openbsd-x64": "3.1.0", + "@koromix/koffi-win32-ia32": "3.1.0", + "@koromix/koffi-win32-x64": "3.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index b735d4cf3..463c1ff32 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -57,6 +57,7 @@ "license": "MIT", "dependencies": { "@github/copilot": "^1.0.69-3", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9f430600c..5488f940b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -40,6 +40,7 @@ import type { } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; +import { FfiRuntimeHost } from "./ffiRuntimeHost.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; import { createCopilotRequestAdapter } from "./copilotRequestHandler.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; @@ -58,6 +59,7 @@ import type { BearerTokenProvider, GetStatusResponse, InternalRuntimeConnection, + RuntimeConnection, LargeToolOutputConfig, MCPServerConfig, ModelInfo, @@ -91,6 +93,29 @@ import { defaultJoinSessionPermissionHandler } from "./types.js"; const MIN_PROTOCOL_VERSION = 3; const RUNTIME_SHUTDOWN_TIMEOUT_MS = 10_000; +/** + * Temporary, env-gated event tracing (COPILOT_EVENT_TRACE=1) for diagnosing in-process + * session stalls (e.g. `sendAndWait` never seeing `session.idle`). Logs each inbound + * `session.event` / `session.lifecycle` notification's type as it is dispatched, to + * stderr. Remove once the in-process event-delivery gap is resolved. + */ +const EVENT_TRACE = process.env.COPILOT_EVENT_TRACE === "1"; +function eventTrace(channel: string, sessionId: string, type: unknown, disposition: string): void { + if (EVENT_TRACE) { + process.stderr.write( + `[evt ${Date.now() % 100000} pid=${process.pid}] ${channel} type=${String(type)} ` + + `sid=${sessionId} ${disposition}\n` + ); + } +} + +let rpcTraceSeq = 0; +function rpcTrace(msg: string): void { + if (EVENT_TRACE) { + process.stderr.write(`[rpc ${Date.now() % 100000} pid=${process.pid}] ${msg}\n`); + } +} + /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -473,6 +498,7 @@ class TeardownResilientStreamMessageWriter extends StreamMessageWriter { export class CopilotClient { private cliStartTimeout: ReturnType | null = null; private cliProcess: ChildProcess | null = null; + private ffiHost: FfiRuntimeHost | null = null; private connection: MessageConnection | null = null; private messageWriter: TeardownResilientStreamMessageWriter | null = null; private socket: Socket | null = null; @@ -563,6 +589,33 @@ export class CopilotClient { } } + /** + * Environment variable that overrides the transport when the caller does not set + * {@link CopilotClientOptions.connection}. Accepts `"inprocess"` or `"stdio"` + * (case-insensitive); unset preserves the default stdio transport. Any other value + * is an error. + */ + private static readonly DEFAULT_CONNECTION_ENV_VAR = "COPILOT_SDK_DEFAULT_CONNECTION"; + + /** + * Resolves the default {@link RuntimeConnection} for the no-connection case, + * honoring {@link CopilotClient.DEFAULT_CONNECTION_ENV_VAR}. + */ + private static resolveDefaultConnection(options: CopilotClientOptions): RuntimeConnection { + const env = options.env ?? process.env; + const value = env[CopilotClient.DEFAULT_CONNECTION_ENV_VAR]; + if (!value || value.toLowerCase() === "stdio") { + return { kind: "stdio" }; + } + if (value.toLowerCase() === "inprocess") { + return { kind: "inprocess" }; + } + throw new Error( + `Invalid ${CopilotClient.DEFAULT_CONNECTION_ENV_VAR} value '${value}'. ` + + `Expected 'inprocess', 'stdio', or unset.` + ); + } + /** * Creates a new CopilotClient instance. * @@ -594,8 +647,10 @@ export class CopilotClient { // Resolve the connection mode. `_internalConnection` is set by // `joinSession()` to opt into the parent-process stdio path; consumers // should always go through the public `connection` field. - const conn: InternalRuntimeConnection = options._internalConnection ?? - options.connection ?? { kind: "stdio" }; + const conn: InternalRuntimeConnection = + options._internalConnection ?? + options.connection ?? + CopilotClient.resolveDefaultConnection(options); if ( conn.kind === "uri" && @@ -810,7 +865,9 @@ export class CopilotClient { try { // Only start CLI server process if not connecting to external server - if (!this.isExternalServer) { + if (this.connectionConfig.kind === "inprocess") { + await this.startInProcessFfi(); + } else if (!this.isExternalServer) { await this.startCLIServer(); } @@ -910,7 +967,7 @@ export class CopilotClient { // Ask SDK-owned runtimes to flush and clean up before we tear down // their transport/process. External runtimes may be shared, so only // close our connection to them. - if (this.connection && this.cliProcess && !this.isExternalServer) { + if (this.connection && (this.cliProcess || this.ffiHost) && !this.isExternalServer) { const runtimeShutdownStart = Date.now(); const shutdownPromise = this.rpc.runtime.shutdown(); void shutdownPromise.catch(() => undefined); @@ -1011,6 +1068,21 @@ export class CopilotClient { ); } } + // Tear down the in-process FFI host (closes the native connection and + // shuts down the native runtime host) for SDK-owned in-process runtimes. + if (this.ffiHost) { + const host = this.ffiHost; + this.ffiHost = null; + try { + host.dispose(); + } catch (error) { + errors.push( + new Error( + `Failed to dispose in-process runtime host: ${error instanceof Error ? error.message : String(error)}` + ) + ); + } + } if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; @@ -1113,6 +1185,16 @@ export class CopilotClient { this.cliProcess = null; } + // Tear down the in-process FFI host (if any). + if (this.ffiHost) { + try { + this.ffiHost.dispose(); + } catch { + // Ignore errors during force stop + } + this.ffiHost = null; + } + if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; @@ -2195,6 +2277,47 @@ export class CopilotClient { }; } + /** + * Builds the environment for the spawned runtime child process (stdio/TCP): applies + * the auth token, connection token, `COPILOT_HOME`, keychain setting, and telemetry + * variables on top of the effective env. Not used by the in-process (FFI) transport, + * whose worker inherits the host process's ambient environment + * (see {@link CopilotClient.startInProcessFfi}). + */ + private buildRuntimeEnv(): Record { + const env: Record = { ...this.resolvedEnv }; + delete env.NODE_DEBUG; + + if (this.options.gitHubToken) { + env.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; + } + if (this.effectiveConnectionToken) { + env.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; + } + if (this.options.baseDirectory) { + env.COPILOT_HOME = this.options.baseDirectory; + } + // In empty mode, disable the system keychain. Keytar reads from a + // process-wide store that's shared across sessions, which is unsafe + // for multi-tenant hosts. The runtime falls back to file-based + // credential storage scoped to COPILOT_HOME. + if (this.options.mode === "empty") { + env.COPILOT_DISABLE_KEYTAR = "1"; + } + if (this.options.telemetry) { + const t = this.options.telemetry; + env.COPILOT_OTEL_ENABLED = "true"; + if (t.otlpEndpoint !== undefined) env.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; + if (t.otlpProtocol !== undefined) env.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; + if (t.filePath !== undefined) env.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; + if (t.exporterType !== undefined) env.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; + if (t.sourceName !== undefined) env.COPILOT_OTEL_SOURCE_NAME = t.sourceName; + if (t.captureContent !== undefined) + env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String(t.captureContent); + } + return env; + } + /** * Start the CLI server process */ @@ -2241,30 +2364,9 @@ export class CopilotClient { args.push("--remote"); } - // Suppress debug/trace output that might pollute stdout - const envWithoutNodeDebug = { ...this.resolvedEnv }; - delete envWithoutNodeDebug.NODE_DEBUG; - - // Set auth token in environment if provided - if (this.options.gitHubToken) { - envWithoutNodeDebug.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; - } - - if (this.effectiveConnectionToken) { - envWithoutNodeDebug.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; - } - - if (this.options.baseDirectory) { - envWithoutNodeDebug.COPILOT_HOME = this.options.baseDirectory; - } - - // In empty mode, disable the system keychain. Keytar reads from a - // process-wide store that's shared across sessions, which is unsafe - // for multi-tenant hosts. The runtime falls back to file-based - // credential storage scoped to COPILOT_HOME. - if (this.options.mode === "empty") { - envWithoutNodeDebug.COPILOT_DISABLE_KEYTAR = "1"; - } + // Suppress debug/trace output that might pollute stdout, and apply the + // shared runtime env (auth token, connection token, COPILOT_HOME, telemetry). + const envWithoutNodeDebug = this.buildRuntimeEnv(); if (!this.resolvedCliPath) { throw new Error( @@ -2276,26 +2378,6 @@ export class CopilotClient { ); } - // Set OpenTelemetry environment variables if telemetry is configured - if (this.options.telemetry) { - const t = this.options.telemetry; - envWithoutNodeDebug.COPILOT_OTEL_ENABLED = "true"; - if (t.otlpEndpoint !== undefined) - envWithoutNodeDebug.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; - if (t.otlpProtocol !== undefined) - envWithoutNodeDebug.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; - if (t.filePath !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; - if (t.exporterType !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; - if (t.sourceName !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_SOURCE_NAME = t.sourceName; - if (t.captureContent !== undefined) - envWithoutNodeDebug.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String( - t.captureContent - ); - } - // Verify CLI exists before attempting to spawn if (!existsSync(this.resolvedCliPath)) { throw new Error( @@ -2432,12 +2514,76 @@ export class CopilotClient { return this.connectToParentProcessViaStdio(); case "stdio": return this.connectToChildProcessViaStdio(); + case "inprocess": + return this.connectViaFfi(); case "tcp": case "uri": return this.connectViaTcp(); } } + /** + * Start the in-process FFI runtime host: resolve the CLI entrypoint and native + * runtime library, then let the native host spawn the CLI worker. + * + * The worker inherits this host process's ambient environment; per-client options + * that lower to environment variables (`env`, `telemetry`, `gitHubToken`, + * `baseDirectory`) are intentionally not applied here, because the native runtime + * loads into the shared host process and a single env block cannot carry per-client + * 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 { + const entrypoint = this.resolveCliPathForFfi(); + const host = FfiRuntimeHost.create( + entrypoint, + CopilotClient.getNapiPrebuildsFolder(), + undefined, + this.options.workingDirectory + ); + this.ffiHost = host; + await host.start(); + } + + /** + * Connect to the in-process FFI runtime host over its receive/send streams, + * reusing the same `vscode-jsonrpc` framing as the stdio transport. + */ + private async connectViaFfi(): Promise { + if (!this.ffiHost) { + throw new Error("In-process FFI runtime host not started"); + } + this.messageWriter = new TeardownResilientStreamMessageWriter(this.ffiHost.sendStream); + this.connection = createMessageConnection( + new StreamMessageReader(this.ffiHost.receiveStream), + this.messageWriter + ); + + this.attachConnectionHandlers(); + this.connection.listen(); + } + + /** + * Resolves the CLI entrypoint used for in-process FFI hosting: `COPILOT_CLI_PATH` + * when set, otherwise the bundled platform-package entrypoint. + */ + private resolveCliPathForFfi(): string { + return this.resolvedEnv.COPILOT_CLI_PATH ?? getBundledCliPath(); + } + + /** + * Returns the napi prebuilds folder name for the current host — the + * `-` convention (e.g. `win32-x64`, `darwin-arm64`, + * `linux-x64`) under which the runtime ships `prebuilds//runtime.node`. + */ + private static getNapiPrebuildsFolder(): string { + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") { + throw new Error(`Unsupported architecture '${arch}' for in-process FFI hosting.`); + } + return `${process.platform}-${arch}`; + } + /** * Connect to child via stdio pipes */ @@ -2534,6 +2680,35 @@ export class CopilotClient { return; } + if (EVENT_TRACE) { + const conn = this.connection; + const original = conn.sendRequest.bind(conn); + (conn as { sendRequest: unknown }).sendRequest = ( + method: unknown, + ...args: unknown[] + ) => { + const seq = ++rpcTraceSeq; + rpcTrace(`>>REQ seq=${seq} method=${String(method)}`); + let result: Promise; + try { + result = original(method as never, ...(args as never[])); + } catch (err) { + rpcTrace(`!!REQ seq=${seq} method=${String(method)} threw=${String(err)}`); + throw err; + } + return Promise.resolve(result).then( + (value) => { + rpcTrace(`< { + rpcTrace(`< { this.handleSessionEventNotification(notification); }); @@ -2620,8 +2795,15 @@ export class CopilotClient { } const session = this.sessions.get((notification as { sessionId: string }).sessionId); + const event = (notification as { event: SessionEvent }).event; + eventTrace( + "session.event", + (notification as { sessionId: string }).sessionId, + (event as { type?: unknown })?.type, + session ? "dispatch" : "no-session" + ); if (session) { - session._dispatchEvent((notification as { event: SessionEvent }).event); + session._dispatchEvent(event); } } @@ -2658,6 +2840,8 @@ export class CopilotClient { metadata, } as SessionLifecycleEvent; + eventTrace("session.lifecycle", raw.sessionId, raw.type, "dispatch"); + // Dispatch to typed handlers for this specific event type const typedHandlers = this.typedLifecycleHandlers.get(event.type); if (typedHandlers) { diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts new file mode 100644 index 000000000..4ef4d1d29 --- /dev/null +++ b/nodejs/src/ffiRuntimeHost.ts @@ -0,0 +1,458 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Hosts the Copilot runtime in-process by loading the native `runtime.node` cdylib + * and speaking JSON-RPC over its C ABI (FFI) instead of spawning a CLI child process + * and communicating over stdio/TCP. + * + * The native `host_start` export spawns the CLI worker itself + * (`node --embedded-host` for a `.js` entrypoint, or ` + * --embedded-host` for a packaged binary), so the SDK never launches the worker + * directly. LSP `Content-Length:`-framed JSON-RPC bytes are pumped across the ABI: + * writes go to `connection_write`; inbound frames arrive on a native callback that + * feeds {@link FfiRuntimeHost.receiveStream}. The existing `vscode-jsonrpc` + * `StreamMessageReader`/`StreamMessageWriter` handle framing unchanged — this is a + * transport swap, not a new protocol. + */ + +import { existsSync } from "node:fs"; +import koffi from "koffi"; +import { dirname, join, resolve } from "node:path"; +import { PassThrough, Writable } from "node:stream"; + +const SYMBOL_PREFIX = "copilot_runtime_"; + +/** Temporary, env-gated (COPILOT_FFI_TRACE=1) inbound-callback liveness tracing. */ +const FFI_TRACE = process.env.COPILOT_FFI_TRACE === "1"; + +type KoffiFunction = ReturnType["func"]>; +type KoffiType = ReturnType; +type KoffiRegisteredCallback = ReturnType; + +interface FfiLibrary { + hostStart: KoffiFunction; + hostShutdown: KoffiFunction; + connectionOpen: KoffiFunction; + connectionWrite: KoffiFunction; + connectionClose: KoffiFunction; + logDroppedCount: KoffiFunction; + outboundCallbackType: KoffiType; +} + +let loadedLibraryPath: string | undefined; +let loadedLibrary: FfiLibrary | undefined; + +/** + * Loads the cdylib once per process and binds the C ABI exports. Loading a + * different library path in the same process is unsupported. + */ +function loadLibrary(libraryPath: string): FfiLibrary { + if (loadedLibrary) { + if (loadedLibraryPath !== libraryPath) { + throw new Error( + `An in-process FFI runtime library is already loaded from '${loadedLibraryPath}'; ` + + `loading a different library from '${libraryPath}' in the same process is not supported.` + ); + } + return loadedLibrary; + } + + const lib = koffi.load(libraryPath); + const outboundCallbackType = koffi.pointer( + koffi.proto( + `void ${SYMBOL_PREFIX}outbound(void *userData, uint8 *bytesPtr, size_t bytesLen)` + ) + ); + + loadedLibrary = { + hostStart: lib.func(`${SYMBOL_PREFIX}host_start`, "uint32", [ + "uint8*", + "size_t", + "uint8*", + "size_t", + ]), + hostShutdown: lib.func(`${SYMBOL_PREFIX}host_shutdown`, "bool", ["uint32"]), + connectionOpen: lib.func(`${SYMBOL_PREFIX}connection_open`, "uint32", [ + "uint32", + outboundCallbackType, + "void*", + "uint8*", + "size_t", + "uint8*", + "size_t", + "uint8*", + "size_t", + ]), + connectionWrite: lib.func(`${SYMBOL_PREFIX}connection_write`, "bool", [ + "uint32", + "uint8*", + "size_t", + ]), + connectionClose: lib.func(`${SYMBOL_PREFIX}connection_close`, "bool", ["uint32"]), + // A no-argument, side-effect-free diagnostic getter (returns a dropped-log + // counter). Used purely as a cheap async FFI call to keep koffi's asynchronous + // callback broker serviced while the connection is idle (see the pump in start()). + logDroppedCount: lib.func(`${SYMBOL_PREFIX}log_dropped_count`, "uint64", []), + outboundCallbackType, + }; + loadedLibraryPath = libraryPath; + return loadedLibrary; +} + +function buildArgvJson(cliEntrypoint: string): Buffer { + // A `.js` entrypoint is launched via node; the packaged single-file CLI binary + // embeds its own Node and is invoked directly. + const argv = cliEntrypoint.toLowerCase().endsWith(".js") + ? ["node", cliEntrypoint, "--embedded-host"] + : [cliEntrypoint, "--embedded-host"]; + return Buffer.from(JSON.stringify(argv), "utf8"); +} + +function buildEnvJson(environment?: Record): Buffer | null { + if (!environment) { + return null; + } + const obj: Record = {}; + for (const [key, value] of Object.entries(environment)) { + if (value !== undefined) { + obj[key] = value; + } + } + if (Object.keys(obj).length === 0) { + return null; + } + return Buffer.from(JSON.stringify(obj), "utf8"); +} + +export class FfiRuntimeHost { + private readonly lib: FfiLibrary; + private serverId = 0; + private connectionId = 0; + private disposed = false; + private outboundCallback: KoffiRegisteredCallback | undefined; + /** + * Keeps koffi's asynchronous callback broker serviced while the FFI connection is + * open, so inbound native→JS frames are delivered promptly even when the SDK is + * otherwise idle (e.g. awaiting a model response with no client→server writes in + * flight). + * + * The runtime invokes our outbound callback from a worker thread. koffi marshals + * such foreign-thread callbacks to the JS main thread via a threadsafe-function + * "broker" that is only serviced WHILE a koffi asynchronous FFI call is in flight — + * a plain JS timer (setInterval) does NOT pump it. During active request/response + * traffic the constant `connection_write` calls keep frames flowing, but once the + * SDK goes idle mid-turn (e.g. the worker is awaiting a model HTTP response, so + * there are no client→server writes) the broker parks and the next outbound frame + * (the model response, then everything after it) sits undelivered until the next + * koffi call — observed as a permanent 30s stall of otherwise-healthy sessions on + * macOS/Windows (libuv services the broker differently on Linux, which is why it + * only reproduces off-Linux). Keeping exactly one cheap async FFI call + * (`log_dropped_count`) continuously in flight keeps the broker pumping so + * foreign-thread frames are always delivered. This is the koffi analogue of the + * .NET host's raw function-pointer callback, which runs directly on the runtime + * thread and needs no event-loop pumping. + */ + private pumpActive = false; + /** + * Inbound frame bytes copied out of the native callback, awaiting delivery to + * {@link receiveStream} on a clean event-loop tick. See {@link feedInbound}. + */ + private readonly inboundQueue: Buffer[] = []; + /** Whether a drain of {@link inboundQueue} is already scheduled. */ + private drainScheduled = false; + + /** The stream JSON-RPC reads server→client frames from. */ + readonly receiveStream: PassThrough; + /** The stream JSON-RPC writes client→server frames to. */ + readonly sendStream: Writable; + + private constructor( + private readonly libraryPath: string, + private readonly cliEntrypoint: string, + private readonly environment?: Record, + private readonly workingDirectory?: string + ) { + this.lib = loadLibrary(libraryPath); + this.receiveStream = new PassThrough(); + this.sendStream = new Writable({ + // Write frames with a synchronous FFI call (restored while diagnosing the + // macOS/Windows inbound-callback stall; async writes were a no-op for it). + write: (chunk: Buffer, _encoding, callback) => { + try { + this.writeFrame(chunk); + callback(); + } catch (error) { + callback(error as Error); + } + }, + }); + } + + /** + * Resolves the cdylib next to the given CLI entrypoint and prepares the FFI host. + * The cdylib is resolved as `prebuilds//runtime.node` relative to + * the entrypoint directory (the napi-rs `-` layout, e.g. + * `linux-x64`). Throws if it cannot be found. + */ + static create( + cliEntrypoint: string, + prebuildsFolder: string, + environment?: Record, + workingDirectory?: string + ): FfiRuntimeHost { + const fullEntrypoint = resolve(cliEntrypoint); + const distDir = dirname(fullEntrypoint); + const libraryPath = join(distDir, "prebuilds", prebuildsFolder, "runtime.node"); + if (!existsSync(libraryPath)) { + throw new Error(`FFI runtime library not found. Looked for '${libraryPath}'.`); + } + return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, workingDirectory); + } + + /** + * Starts the in-process runtime: spawns the CLI worker via the native host, + * waits for readiness, and opens the FFI JSON-RPC connection. + */ + async start(): Promise { + const argvJson = buildArgvJson(this.cliEntrypoint); + const envJson = buildEnvJson(this.environment); + + // The native host spawns the CLI worker itself and has no cwd parameter, so the + // worker inherits this process's cwd. Mirror the stdio child's `cwd: workingDirectory` + // by switching cwd for the duration of the blocking host_start, then restoring it. + const previousCwd = process.cwd(); + const shouldSwitchCwd = !!this.workingDirectory && this.workingDirectory !== previousCwd; + if (shouldSwitchCwd) { + process.chdir(this.workingDirectory!); + } + + // host_start blocks until the worker connects back and signals readiness + // (up to ~30s); run it as an async FFI call so the Node event loop isn't blocked. + try { + this.serverId = await new Promise((resolvePromise, rejectPromise) => { + this.lib.hostStart.async( + argvJson, + argvJson.length, + envJson, + envJson ? envJson.length : 0, + (error: Error | null, result: number) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(result); + } + } + ); + }); + } finally { + if (shouldSwitchCwd) { + process.chdir(previousCwd); + } + } + if (!this.serverId) { + throw new Error( + `copilot_runtime_host_start failed (library '${this.libraryPath}', entrypoint '${this.cliEntrypoint}').` + ); + } + + this.outboundCallback = koffi.register( + (_userData: unknown, bytesPtr: unknown, bytesLen: number | bigint) => + this.feedInbound(bytesPtr, bytesLen), + this.lib.outboundCallbackType + ); + + this.connectionId = this.lib.connectionOpen( + this.serverId, + this.outboundCallback, + null, + null, + 0, + null, + 0, + null, + 0 + ); + if (!this.connectionId) { + this.unregisterCallback(); + this.lib.hostShutdown(this.serverId); + this.serverId = 0; + throw new Error("copilot_runtime_connection_open failed."); + } + + this.startBrokerPump(); + } + + /** + * Keeps exactly one cheap async FFI call in flight at all times so koffi's + * threadsafe-function broker stays serviced and foreign-thread outbound callbacks + * are delivered without waiting for the next client→server write. Each call + * reschedules the next on completion; the in-flight libuv request also keeps the + * event loop alive (replacing the old keep-alive timer). Stops when disposed. + */ + private startBrokerPump(): void { + if (this.pumpActive) { + return; + } + this.pumpActive = true; + const pump = (): void => { + if (this.disposed || !this.connectionId) { + this.pumpActive = false; + return; + } + this.lib.logDroppedCount.async((_error: Error | null, _result: unknown) => { + if (this.disposed || !this.connectionId) { + this.pumpActive = false; + return; + } + pump(); + }); + }; + pump(); + } + + private writeFrame(frame: Buffer): void { + if (this.disposed || !this.connectionId) { + throw new Error("The in-process runtime connection is closed."); + } + const ok = this.lib.connectionWrite(this.connectionId, frame, frame.length); + if (!ok) { + throw new Error("Failed to write a frame to the in-process runtime connection."); + } + } + + /** + * Native outbound callback: copies the inbound frame bytes and hands them to the + * event loop for delivery, WITHOUT driving the JSON-RPC reader synchronously here. + * + * The native pointer is only valid for the duration of this call, so the bytes are + * decoded/copied eagerly; but writing them to {@link receiveStream} (which + * synchronously drives frame parsing and JSON-RPC dispatch, and may re-enter the + * SDK's write path) is deferred to a `setImmediate` drain on a clean stack. This + * keeps the callback minimal and non-reentrant — the koffi analogue of the .NET + * host's thread-safe `Channel` callback, which enqueues and returns immediately. + * Delivering synchronously here could re-enter a native `connection_write` call + * still on the stack and deadlock (observed as hung `session.rpc.*` round-trips + * on macOS). + */ + private inboundSeq = 0; + + private feedInbound(bytesPtr: unknown, bytesLen: number | bigint): void { + // This runs as a native→JS (Node-API) callback, possibly from a secondary + // thread. An exception thrown here cannot propagate across the FFI boundary and + // is swallowed by the runtime (surfacing only as a DEP0168 "Uncaught Node-API + // callback exception" warning), so catch and log it here instead of letting it + // escape. + const seq = ++this.inboundSeq; + if (FFI_TRACE) { + process.stderr.write( + `[ffi ${Date.now() % 100000} pid=${process.pid}] CB-ENTER seq=${seq} len=${Number(bytesLen)}\n` + ); + } + try { + const length = Number(bytesLen); + if (!bytesPtr || length <= 0) { + return; + } + const bytes = koffi.decode( + bytesPtr, + koffi.array("uint8", length, "Typed") + ) as Uint8Array; + this.inboundQueue.push(Buffer.from(bytes)); + if (!this.drainScheduled) { + this.drainScheduled = true; + setImmediate(() => this.drainInbound()); + } + } catch (error) { + console.error( + `In-process FFI inbound callback failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` + ); + } finally { + if (FFI_TRACE) { + process.stderr.write( + `[ffi ${Date.now() % 100000} pid=${process.pid}] CB-EXIT seq=${seq}\n` + ); + } + } + } + + /** Delivers queued inbound frames to {@link receiveStream} on a clean event-loop tick. */ + private drainInbound(): void { + this.drainScheduled = false; + if (this.disposed) { + this.inboundQueue.length = 0; + return; + } + let frame: Buffer | undefined; + while ((frame = this.inboundQueue.shift()) !== undefined) { + if (FFI_TRACE) { + process.stderr.write( + `[ffi ${Date.now() % 100000} pid=${process.pid}] DRAIN len=${frame.length}\n` + ); + } + this.receiveStream.write(frame); + } + } + + private unregisterCallback(): void { + if (this.outboundCallback === undefined) { + return; + } + const callback = this.outboundCallback; + this.outboundCallback = undefined; + // Defer the unregister to a later tick instead of unregistering synchronously. + // koffi delivers outbound callbacks from a secondary thread by queuing them onto + // the JS event loop; at teardown one such delivery can still be queued after we + // stop the native side. Unregistering while koffi still has a queued call makes + // koffi invoke a torn-down callback and raise inside its own native code — an + // uncaught Node-API callback exception (DEP0168) that no JS try/catch can catch. + // The native connection/host are already closed by the time this runs (see + // dispose), so no new deliveries originate; a queued delivery fires in libuv's + // poll phase and setImmediate (check phase) runs right after it in the same loop + // iteration, so the pending delivery (a no-op, since we are disposed) drains + // before we free the slot. + const immediate = setImmediate(() => { + try { + koffi.unregister(callback); + } catch { + // Ignore teardown failures. + } + }); + // Don't let this housekeeping timer keep the process alive. + immediate.unref?.(); + } + + /** Closes the FFI connection, shuts down the native host, and releases resources. */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + + // The broker pump observes `disposed` and stops rescheduling on its next + // completion; no timer to clear. + this.pumpActive = false; + + try { + if (this.connectionId) { + this.lib.connectionClose(this.connectionId); + this.connectionId = 0; + } + } catch { + // Ignore teardown failures. + } + + try { + if (this.serverId) { + this.lib.hostShutdown(this.serverId); + this.serverId = 0; + } + } catch { + // Ignore teardown failures. + } + + this.receiveStream.end(); + this.unregisterCallback(); + } +} diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index e05b33c15..cf65cb653 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -59,6 +59,7 @@ export type { CopilotClientMode, CopilotClientOptions, StdioRuntimeConnection, + InProcessRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, CustomAgentConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 8d8fc6714..3ec5b7ee6 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -299,6 +299,12 @@ export class CopilotSession { // Register event handler BEFORE calling send to avoid race condition // where session.idle fires before we start listening const unsubscribe = this.on((event) => { + if (process.env.COPILOT_EVENT_TRACE === "1") { + process.stderr.write( + `[evt ${Date.now() % 100000} pid=${process.pid}] sendAndWait-recv ` + + `type=${event.type} sid=${this.sessionId}\n` + ); + } if (event.type === "assistant.message") { lastAssistantMessage = event; } else if (event.type === "session.idle") { @@ -657,6 +663,12 @@ export class CopilotSession { sessionId: this.sessionId, }); if (result.kind === "no-result") { + if (process.env.COPILOT_EVENT_TRACE === "1") { + process.stderr.write( + `[evt ${Date.now() % 100000} pid=${process.pid}] no-result-skip ` + + `requestId=${requestId} sid=${this.sessionId}\n` + ); + } return; } if (this.disconnected) { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 97182b5f1..de2055857 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -96,6 +96,7 @@ export interface TelemetryConfig { */ export type RuntimeConnection = | StdioRuntimeConnection + | InProcessRuntimeConnection | TcpRuntimeConnection | UriRuntimeConnection; @@ -111,6 +112,26 @@ export interface StdioRuntimeConnection { readonly args?: readonly string[]; } +/** + * Hosts the runtime in-process by loading the native runtime library and speaking + * JSON-RPC over its C ABI (FFI), instead of spawning a runtime child process. The + * native host spawns the CLI worker itself. Construct via + * {@link RuntimeConnection.forInProcess}. + * + * @experimental The in-process (FFI) transport is experimental and its behavior may + * change. Per-client options that are lowered to environment variables — including + * {@link CopilotClientOptions.env}, {@link CopilotClientOptions.telemetry}, + * {@link CopilotClientOptions.gitHubToken}, and + * {@link CopilotClientOptions.baseDirectory} — are **not** honored with this + * transport, because the native runtime loads into the shared host process and its + * worker inherits that process's ambient environment. To configure the in-process + * runtime, set the corresponding environment variables on the host process before + * constructing the client. See https://github.com/github/copilot-sdk/issues/1934. + */ +export interface InProcessRuntimeConnection { + readonly kind: "inprocess"; +} + /** * Spawns a runtime child process that listens on a TCP socket and connects to it. */ @@ -183,6 +204,21 @@ export const RuntimeConnection = { forUri(url: string, opts: { connectionToken?: string } = {}): UriRuntimeConnection { return { kind: "uri", url, connectionToken: opts.connectionToken }; }, + /** + * Host the runtime in-process over the native runtime library's C ABI (FFI). + * The native host spawns the CLI worker itself; the SDK does not launch a + * runtime child process. Honors `COPILOT_CLI_PATH` for the CLI entrypoint, + * otherwise resolves the bundled platform package. + * + * @experimental Per-client options lowered to environment variables (`env`, + * `telemetry`, `gitHubToken`, `baseDirectory`) are **not** honored in-process; + * the worker inherits the host process's ambient environment. Set the + * corresponding environment variables on the host process instead. See + * https://github.com/github/copilot-sdk/issues/1934. + */ + forInProcess(): InProcessRuntimeConnection { + return { kind: "inprocess" }; + }, } as const; /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96c32a595..8605f8d82 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -2293,22 +2293,29 @@ describe("CopilotClient", () => { }); describe("unexpected disconnection", () => { - it("transitions to disconnected when child process is killed", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => client.forceStop()); - - expect((client as any).state).toBe("connected"); - - // Kill the child process to simulate unexpected termination - const proc = (client as any).cliProcess as import("node:child_process").ChildProcess; - proc.kill(); - - // Wait for the connection.onClose handler to fire - await vi.waitFor(() => { - expect((client as any).state).toBe("disconnected"); - }); - }); + // No child process exists over the in-process (FFI) transport, so this + // child-process-kill scenario does not apply there. Covered by the default + // (stdio) cell. + it.skipIf((process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess")( + "transitions to disconnected when child process is killed", + async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + expect((client as any).state).toBe("connected"); + + // Kill the child process to simulate unexpected termination + const proc = (client as any) + .cliProcess as import("node:child_process").ChildProcess; + proc.kill(); + + // Wait for the connection.onClose handler to fire + await vi.waitFor(() => { + expect((client as any).state).toBe("disconnected"); + }); + } + ); }); describe("onGetTraceContext", () => { diff --git a/nodejs/test/e2e/_koffi_callback_repro.e2e.test.ts b/nodejs/test/e2e/_koffi_callback_repro.e2e.test.ts new file mode 100644 index 000000000..2b80ab678 --- /dev/null +++ b/nodejs/test/e2e/_koffi_callback_repro.e2e.test.ts @@ -0,0 +1,301 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Standalone repro for the in-process FFI hang: does koffi deliver a foreign-thread + * callback to JS when the main event loop is otherwise idle (only a keep-alive timer)? + * + * No SDK, no runtime cdylib, no JSON-RPC — just a tiny C shared library whose exported + * function spawns a background thread that, after a delay, invokes a koffi-registered + * callback. Mirrors how the runtime's worker-reader thread invokes our outbound + * callback while the SDK sits idle awaiting a response. + * + * Compiled on the fly with cc/clang (Linux + macOS). Skipped where no C compiler / + * POSIX threads are available (e.g. Windows without a toolchain). + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import koffi from "koffi"; + +const C_SOURCE = ` +#include +#include + +typedef void (*ping_cb)(int seq); + +static ping_cb g_cb; +static int g_delay_ms; +static int g_count; + +static void *pinger(void *arg) { + (void)arg; + for (int i = 0; i < g_count; i++) { + usleep(g_delay_ms * 1000); + if (g_cb) g_cb(i); + } + return 0; +} + +// Spawn a detached background thread that invokes cb 'count' times, 'delay_ms' apart. +void start_pinger(ping_cb cb, int delay_ms, int count) { + g_cb = cb; + g_delay_ms = delay_ms; + g_count = count; + pthread_t t; + pthread_create(&t, 0, pinger, 0); + pthread_detach(t); +} + +// A side-effect-free call used to mimic the SDK's async FFI traffic (writes/pump) +// happening concurrently with background-thread callbacks. +int noop(void) { return 0; } + +typedef void (*blob_cb)(int seq, const unsigned char *data, int len); + +static blob_cb g_blob_cb; +static int g_burst; +static int g_big_at; +static int g_big_len; + +static void *burster(void *arg) { + (void)arg; + // Small settle so the JS main loop is idle/parked before the burst starts. + usleep(200 * 1000); + static unsigned char buf[65536]; + for (int i = 0; i < g_burst; i++) { + int len = (i == g_big_at) ? g_big_len : 64; + for (int j = 0; j < len; j++) buf[j] = (unsigned char)(i + j); + if (g_blob_cb) g_blob_cb(i, buf, len); + } + return 0; +} + +// Blast 'burst' callbacks back-to-back (no delay), one of them 'big_len' bytes, +// mirroring a streaming turn's rapid inbound frames including a large payload. +void start_burster(blob_cb cb, int burst, int big_at, int big_len) { + g_blob_cb = cb; + g_burst = burst; + g_big_at = big_at; + g_big_len = big_len; + pthread_t t; + pthread_create(&t, 0, burster, 0); + pthread_detach(t); +} + +// Bidirectional reentrancy repro. A background "reader" thread invokes an inbound +// callback for each frame; the JS callback may synchronously call back into +// 'sink' (mimicking the SDK's synchronous connection_write from inside the koffi +// inbound callback). Because koffi relays foreign-thread callbacks with +// napi_tsfn_blocking, the reader thread is blocked in the callback while JS runs — +// and JS re-enters native via 'sink' on the main thread. This is the exact +// native->JS->native reentrancy the in-process transport performs under streaming. +typedef void (*inbound_cb)(int seq); + +static inbound_cb g_inbound; +static int g_frames; +static volatile int g_sink_count; + +// Called by JS from inside the inbound callback (like connection_write). Cheap. +void sink(int seq) { (void)seq; g_sink_count++; } + +static void *reader(void *arg) { + (void)arg; + usleep(150 * 1000); + for (int i = 0; i < g_frames; i++) { + if (g_inbound) g_inbound(i); // blocks until the JS callback returns + } + return 0; +} + +void start_reader(inbound_cb cb, int frames) { + g_inbound = cb; + g_frames = frames; + g_sink_count = 0; + pthread_t t; + pthread_create(&t, 0, reader, 0); + pthread_detach(t); +} +`; + +function findCc(): string | null { + for (const cc of ["cc", "clang", "gcc"]) { + try { + execFileSync(cc, ["--version"], { stdio: "ignore" }); + return cc; + } catch { + // try next + } + } + return null; +} + +function buildLibrary(): string | null { + const cc = findCc(); + if (!cc || process.platform === "win32") { + return null; + } + const dir = mkdtempSync(join(tmpdir(), "koffi-cb-repro-")); + const src = join(dir, "pinger.c"); + const ext = process.platform === "darwin" ? "dylib" : "so"; + const lib = join(dir, `libpinger.${ext}`); + writeFileSync(src, C_SOURCE); + try { + execFileSync(cc, ["-shared", "-fPIC", "-o", lib, src, "-lpthread"], { stdio: "ignore" }); + } catch { + return null; + } + return existsSync(lib) ? lib : null; +} + +describe("koffi foreign-thread callback delivery repro", () => { + const libPath = buildLibrary(); + + it.runIf(libPath)( + "delivers background-thread callbacks while the main loop is idle", + async () => { + const lib = koffi.load(libPath!); + const pingCb = koffi.pointer(koffi.proto("void ping_cb(int seq)")); + const startPinger = lib.func("void start_pinger(ping_cb *cb, int delay_ms, int count)"); + + const received: number[] = []; + const receivedAt: number[] = []; + const total = 5; + + // Keep-alive timer, exactly like FfiRuntimeHost: keeps the loop alive but the + // main thread is otherwise IDLE between callbacks. + const keepAlive = setInterval(() => {}, 4); + + const done = new Promise((resolve) => { + const cb = koffi.register((seq: number) => { + received.push(seq); + receivedAt.push(Date.now()); + if (received.length >= total) { + resolve(); + } + }, pingCb); + + // Background thread pings every 500ms — long enough that the main loop + // goes fully idle (only the keep-alive timer) between pings. + startPinger(cb, 500, total); + }); + + const settled = await Promise.race([ + done.then(() => "done" as const), + new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 15_000)), + ]); + + clearInterval(keepAlive); + + process.stderr.write( + `[repro] received ${received.length}/${total} (${settled}); ` + + `gaps(ms)=${receivedAt.map((t, i) => (i ? t - receivedAt[i - 1] : 0)).join(",")}\n` + ); + + expect(settled).toBe("done"); + expect(received).toEqual([0, 1, 2, 3, 4]); + }, + 20_000 + ); + + it.runIf(libPath)( + "delivers a rapid burst of callbacks (incl. a large payload) after going idle", + async () => { + const lib = koffi.load(libPath!); + const blobCb = koffi.pointer( + koffi.proto("void blob_cb(int seq, uint8 *data, int len)") + ); + const startBurster = lib.func( + "void start_burster(blob_cb *cb, int burst, int big_at, int big_len)" + ); + + const burst = 40; + const bigAt = 20; + const bigLen = 23638; // same size as the streaming frame that preceded the stall + const received: number[] = []; + const lengths: number[] = []; + + // Keep-alive timer exactly like FfiRuntimeHost. + const keepAlive = setInterval(() => {}, 4); + + const done = new Promise((resolve) => { + const cb = koffi.register((seq: number, dataPtr: unknown, len: number) => { + // Decode like feedInbound does, to exercise the same path. + koffi.decode(dataPtr, koffi.array("uint8", len, "Typed")); + received.push(seq); + lengths.push(len); + if (received.length >= burst) { + resolve(); + } + }, blobCb); + startBurster(cb, burst, bigAt, bigLen); + }); + + const settled = await Promise.race([ + done.then(() => "done" as const), + new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 15_000)), + ]); + + clearInterval(keepAlive); + + process.stderr.write( + `[repro-burst] received ${received.length}/${burst} (${settled}); ` + + `bigLen=${lengths[bigAt] ?? "n/a"}\n` + ); + + expect(settled).toBe("done"); + expect(received.length).toBe(burst); + expect(lengths[bigAt]).toBe(bigLen); + }, + 20_000 + ); + + it.runIf(libPath)( + "delivers frames when the JS callback re-enters native synchronously", + async () => { + const lib = koffi.load(libPath!); + const inboundCb = koffi.pointer(koffi.proto("void inbound_cb(int seq)")); + const startReader = lib.func("void start_reader(inbound_cb *cb, int frames)"); + // Synchronous native call issued from inside the inbound callback, mimicking + // the SDK's synchronous connection_write from inside feedInbound. + const sink = lib.func("void sink(int seq)"); + + const frames = 60; + const received: number[] = []; + + const keepAlive = setInterval(() => {}, 4); + + const done = new Promise((resolve) => { + const cb = koffi.register((seq: number) => { + // Re-enter native synchronously while this callback (running on, and + // blocking, the reader thread relay) is on the stack. + sink(seq); + received.push(seq); + if (received.length >= frames) { + resolve(); + } + }, inboundCb); + startReader(cb, frames); + }); + + const settled = await Promise.race([ + done.then(() => "done" as const), + new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 15_000)), + ]); + + clearInterval(keepAlive); + + process.stderr.write( + `[repro-reentrant] received ${received.length}/${frames} (${settled})\n` + ); + + expect(settled).toBe("done"); + expect(received.length).toBe(frames); + }, + 20_000 + ); +}); diff --git a/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts b/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts index 5a9cad5a5..6b3ff5c43 100644 --- a/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts +++ b/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { approveAll, CopilotRequestHandler, type CopilotRequestContext } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; /** * Cancellation and error coverage for {@link CopilotRequestHandler}. These two @@ -162,23 +162,31 @@ describe("CopilotRequestHandler observes runtime cancellation", async () => { copilotClientOptions: { requestHandler: handler }, }); - it("fires ctx.signal when the consumer aborts an in-flight inference request", async () => { - await client.start(); - const session = await client.createSession({ onPermissionRequest: approveAll }); - try { - await session.send("Say OK."); - await waitFor(() => handler.inferenceEntered, 60_000); - await session.abort(); - await waitFor(() => handler.sawAbort, 30_000); - } finally { - await session.disconnect(); - } + // A CopilotRequestHandler registers a process-wide LLM inference provider. Over the + // in-process transport every client shares the one host process, so a second client + // cannot register while another still holds the provider. Covered by the default + // (stdio) cell, where each client owns its own process. + it.skipIf(isInProcessTransport)( + "fires ctx.signal when the consumer aborts an in-flight inference request", + async () => { + await client.start(); + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await session.send("Say OK."); + await waitFor(() => handler.inferenceEntered, 60_000); + await session.abort(); + await waitFor(() => handler.sawAbort, 30_000); + } finally { + await session.disconnect(); + } - expect(handler.inferenceEntered, "expected the inference callback to be entered").toBe( - true - ); - expect(handler.sawAbort, "expected the callback to observe runtime cancellation").toBe( - true - ); - }, 90_000); + expect(handler.inferenceEntered, "expected the inference callback to be entered").toBe( + true + ); + expect(handler.sawAbort, "expected the callback to observe runtime cancellation").toBe( + true + ); + }, + 90_000 + ); }); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index a59f62126..f2d474449 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -16,6 +16,29 @@ import { formatError, retry } from "./sdkTestHelper"; export const isCI = process.env.GITHUB_ACTIONS === "true"; export const DEFAULT_GITHUB_TOKEN = "fake-token-for-e2e-tests"; +/** + * True when the E2E suite is running over the in-process (FFI) transport + * (COPILOT_SDK_DEFAULT_CONNECTION=inprocess). Use with `it.skipIf` / `describe.skipIf` + * to skip tests for features that are not supported over the in-process transport (the + * runtime loads into the shared host process), so the in-process CI cell stays green. + * Such features are covered by the default (stdio) cell. + */ +export const isInProcessTransport = + (process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess"; + +// The in-process (FFI) transport resolves auth host-side, in this test process, and +// ranks HMAC above the GitHub token — so an ambient COPILOT_HMAC_KEY (CI sets one as a +// job-level credential) would be picked over the SDK/Bearer token the replay snapshots +// expect, yielding 401s. Host-side auth can capture the key as early as client +// construction (before any per-test beforeEach runs), so neutralize it at module load — +// the analogue of .NET's InProcessEnvIsolation `[ModuleInitializer]`. Only applied for +// the in-process transport; stdio/tcp children resolve auth in their own process where +// the token already outranks HMAC. See https://github.com/github/copilot-sdk/issues/1934. +if ((process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess") { + delete process.env.COPILOT_HMAC_KEY; + delete process.env.CAPI_HMAC_KEY; +} + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); @@ -102,11 +125,17 @@ export async function createSdkTestContext({ } else { connection = userConn; } + } else if (useStdio === false) { + connection = RuntimeConnection.forTcp({ path: cliPath }); + } else if ( + useStdio === undefined && + (process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess" + ) { + // The in-process FFI transport resolves the CLI entrypoint itself + // (COPILOT_CLI_PATH or the bundled platform package), so no path is passed. + connection = RuntimeConnection.forInProcess(); } else { - connection = - useStdio === false - ? RuntimeConnection.forTcp({ path: cliPath }) - : RuntimeConnection.forStdio({ path: cliPath }); + connection = RuntimeConnection.forStdio({ path: cliPath }); } const { @@ -114,9 +143,35 @@ export async function createSdkTestContext({ env: userEnv, ...remainingClientOptions } = copilotClientOptions ?? {}; + + const mergedEnv = { ...env, ...userEnv }; + + // The in-process (FFI) transport loads the runtime into this test host process, + // and its worker inherits this process's ambient environment rather than a + // per-client env block (see https://github.com/github/copilot-sdk/issues/1934). + // So the per-test redirects, isolated home, and credentials must be mirrored onto + // the real process environment. Node's `process.env` writes reach native `getenv`, + // so host-side runtime reads (auth resolution, GitHub API redirect) observe them. + // Auth flows via GH_TOKEN/GITHUB_TOKEN here (the FFI argv omits the stdio + // `--auth-token-env COPILOT_SDK_AUTH_TOKEN` wiring), and HMAC is disabled so + // host-side auth resolution picks the SDK/Bearer token the replay snapshots expect. + const isInProcess = connection.kind === "inprocess"; + const inProcessEnv: Record = isInProcess + ? { + ...(mergedEnv as Record), + GH_TOKEN: authTokenToUse, + GITHUB_TOKEN: authTokenToUse, + COPILOT_HMAC_KEY: "", + CAPI_HMAC_KEY: "", + } + : {}; + const copilotClient = new CopilotClient({ workingDirectory: workDir, - env: { ...env, ...userEnv }, + // In-process hosting mirrors the environment onto the real process (per test, in + // beforeEach below), so the worker inherits it; passing a per-client env here + // would have no effect. + env: isInProcess ? undefined : mergedEnv, logLevel: logLevel || "error", connection, gitHubToken: authTokenToUse, @@ -128,6 +183,9 @@ export async function createSdkTestContext({ // Track if any test fails to avoid writing corrupted snapshots let anyTestFailed = false; + // Holds the process.env entries the current test overwrote, so afterEach restores them. + let restoreProcessEnv: Array<[string, string | undefined]> = []; + // Wire up to Vitest lifecycle beforeEach(async (testContext) => { // Must be inside beforeEach - vitest requires test context @@ -135,6 +193,16 @@ export async function createSdkTestContext({ anyTestFailed = true; }); + // Mirror this context's environment onto the real process for in-process + // hosting, right before the test runs (see the comment above the client). The + // client auto-starts on first use inside the test body, so the worker spawns + // under these values. + restoreProcessEnv = []; + for (const [key, value] of Object.entries(inProcessEnv)) { + restoreProcessEnv.push([key, process.env[key]]); + process.env[key] = value; + } + await openAiEndpoint.updateConfig({ filePath: getTrafficCapturePath(testContext), workDir, @@ -146,6 +214,15 @@ export async function createSdkTestContext({ }); afterEach(async () => { + // Undo this test's process.env mirror so it can't leak into the next test/suite. + for (const [key, previous] of restoreProcessEnv.reverse()) { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + restoreProcessEnv = []; // Empty directories but leave them in place for next test await rimraf([join(homeDir, "*"), join(workDir, "*")], { glob: true }); }); diff --git a/nodejs/test/e2e/inprocess_ffi.e2e.test.ts b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts new file mode 100644 index 000000000..28b0380a9 --- /dev/null +++ b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, onTestFinished } from "vitest"; +import { CopilotClient, RuntimeConnection } from "../../src/index.js"; + +function onTestFinishedForceStop(client: CopilotClient) { + onTestFinished(async () => { + try { + await client.forceStop(); + } catch { + // Ignore cleanup errors - process may already be stopped + } + }); +} + +describe("In-process FFI transport", () => { + // Mirrors the .NET `Should_Start_And_Connect_Over_InProcess_Ffi`. Resolution of the + // in-process transport from COPILOT_SDK_DEFAULT_CONNECTION is exercised by the full + // E2E suite running under the `inprocess` CI matrix cell, not a dedicated test. + it("should start and connect over in-process FFI", async () => { + // In-process FFI hosting resolves the CLI entrypoint (COPILOT_CLI_PATH or the + // bundled platform package) and its sibling native runtime library itself. If + // neither is available, start() throws and the test fails hard. + const client = new CopilotClient({ connection: RuntimeConnection.forInProcess() }); + onTestFinishedForceStop(client); + + await client.start(); + + const pong = await client.ping("ffi message"); + expect(pong.message).toBe("pong: ffi message"); + expect(Date.parse(pong.timestamp)).not.toBeNaN(); + + expect(await client.stop()).toHaveLength(0); // No errors on stop + }); +}); diff --git a/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts index d7f478e1f..a09fbb1f7 100644 --- a/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts +++ b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts @@ -5,7 +5,7 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { approveAll } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; describe("Session workspace checkpoint RPC", async () => { const { copilotClient: client } = await createSdkTestContext(); @@ -20,17 +20,25 @@ describe("Session workspace checkpoint RPC", async () => { } }); - it("should return null or empty content for unknown checkpoint", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); - try { - const result = await session.rpc.workspaces.readCheckpoint({ - number: Number.MAX_SAFE_INTEGER, - }); - expect(result.content ?? "").toBe(""); - } finally { - await session.disconnect(); + // Over the in-process transport session.workspaces.readCheckpoint is answered by the + // native runtime, which decodes the checkpoint number as a u32 and rejects the + // MAX_SAFE_INTEGER sentinel this test uses. Covered by the default (stdio) cell; the + // in-process u32 limitation is a runtime gap tracked in + // https://github.com/github/copilot-sdk/issues/1934. + it.skipIf(isInProcessTransport)( + "should return null or empty content for unknown checkpoint", + async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.workspaces.readCheckpoint({ + number: Number.MAX_SAFE_INTEGER, + }); + expect(result.content ?? "").toBe(""); + } finally { + await session.disconnect(); + } } - }); + ); it("should return typed workspace diff result", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); diff --git a/nodejs/test/e2e/telemetry.e2e.test.ts b/nodejs/test/e2e/telemetry.e2e.test.ts index 9df6b7f88..4fe3612f7 100644 --- a/nodejs/test/e2e/telemetry.e2e.test.ts +++ b/nodejs/test/e2e/telemetry.e2e.test.ts @@ -7,7 +7,7 @@ import { join } from "path"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { approveAll, defineTool } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage } from "./harness/sdkTestHelper.js"; interface TelemetryEntry { @@ -67,86 +67,94 @@ describe("Telemetry export", async () => { }, }); - it("should export file telemetry for sdk interactions", { timeout: 90_000 }, async () => { - const session = await client.createSession({ - onPermissionRequest: approveAll, - tools: [ - defineTool(toolName, { - description: "Echoes a marker string for telemetry validation.", - parameters: z.object({ value: z.string() }), - handler: ({ value }) => value, - }), - ], - }); - - await session.send({ prompt }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage).toBeDefined(); - expect(assistantMessage.data.content ?? "").toContain("TELEMETRY_E2E_DONE"); - - await session.disconnect(); - await client.stop(); - - // Telemetry exporter writes to telemetryFileName resolved relative to the CLI cwd (workDir). - const telemetryPath = join(workDir, telemetryFileName); - const entries = await readTelemetryEntries(telemetryPath); - const spans = entries.filter((entry) => entry.type === "span"); - - expect(spans.length).toBeGreaterThan(0); - for (const span of spans) { - expect(span.instrumentationScope?.name).toBe(sourceName); - } - - // All spans for one SDK turn must share the same trace id and must not be in error state. - const traceIds = Array.from( - new Set(spans.map((span) => span.traceId).filter((id): id is string => Boolean(id))) - ); - expect(traceIds).toHaveLength(1); - for (const span of spans) { - expect(span.status?.code).not.toBe(2); - } - - const invokeAgentSpan = spans.find( - (span) => getStringAttribute(span, "gen_ai.operation.name") === "invoke_agent" - ); - expect(invokeAgentSpan).toBeDefined(); - expect(getStringAttribute(invokeAgentSpan!, "gen_ai.conversation.id")).toBe( - session.sessionId - ); - expect(isRootSpan(invokeAgentSpan!)).toBe(true); - const invokeAgentSpanId = invokeAgentSpan!.spanId; - expect(invokeAgentSpanId).toBeTruthy(); - - const chatSpans = spans.filter( - (span) => getStringAttribute(span, "gen_ai.operation.name") === "chat" - ); - expect(chatSpans.length).toBeGreaterThan(0); - for (const chat of chatSpans) { - expect(chat.parentSpanId).toBe(invokeAgentSpanId); - } - expect( - chatSpans.some((span) => - (getStringAttribute(span, "gen_ai.input.messages") ?? "").includes(prompt) - ) - ).toBe(true); - expect( - chatSpans.some((span) => - (getStringAttribute(span, "gen_ai.output.messages") ?? "").includes( - "TELEMETRY_E2E_DONE" + // Telemetry is configured via environment variables the runtime reads, which the + // in-process transport cannot carry per-client (the runtime runs in the shared host + // process); see https://github.com/github/copilot-sdk/issues/1934. Covered by the + // default (stdio) cell. + it.skipIf(isInProcessTransport)( + "should export file telemetry for sdk interactions", + { timeout: 90_000 }, + async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool(toolName, { + description: "Echoes a marker string for telemetry validation.", + parameters: z.object({ value: z.string() }), + handler: ({ value }) => value, + }), + ], + }); + + await session.send({ prompt }); + const assistantMessage = await getFinalAssistantMessage(session); + expect(assistantMessage).toBeDefined(); + expect(assistantMessage.data.content ?? "").toContain("TELEMETRY_E2E_DONE"); + + await session.disconnect(); + await client.stop(); + + // Telemetry exporter writes to telemetryFileName resolved relative to the CLI cwd (workDir). + const telemetryPath = join(workDir, telemetryFileName); + const entries = await readTelemetryEntries(telemetryPath); + const spans = entries.filter((entry) => entry.type === "span"); + + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.instrumentationScope?.name).toBe(sourceName); + } + + // All spans for one SDK turn must share the same trace id and must not be in error state. + const traceIds = Array.from( + new Set(spans.map((span) => span.traceId).filter((id): id is string => Boolean(id))) + ); + expect(traceIds).toHaveLength(1); + for (const span of spans) { + expect(span.status?.code).not.toBe(2); + } + + const invokeAgentSpan = spans.find( + (span) => getStringAttribute(span, "gen_ai.operation.name") === "invoke_agent" + ); + expect(invokeAgentSpan).toBeDefined(); + expect(getStringAttribute(invokeAgentSpan!, "gen_ai.conversation.id")).toBe( + session.sessionId + ); + expect(isRootSpan(invokeAgentSpan!)).toBe(true); + const invokeAgentSpanId = invokeAgentSpan!.spanId; + expect(invokeAgentSpanId).toBeTruthy(); + + const chatSpans = spans.filter( + (span) => getStringAttribute(span, "gen_ai.operation.name") === "chat" + ); + expect(chatSpans.length).toBeGreaterThan(0); + for (const chat of chatSpans) { + expect(chat.parentSpanId).toBe(invokeAgentSpanId); + } + expect( + chatSpans.some((span) => + (getStringAttribute(span, "gen_ai.input.messages") ?? "").includes(prompt) ) - ) - ).toBe(true); - - const toolSpan = spans.find( - (span) => getStringAttribute(span, "gen_ai.operation.name") === "execute_tool" - ); - expect(toolSpan).toBeDefined(); - expect(toolSpan!.parentSpanId).toBe(invokeAgentSpanId); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.name")).toBe(toolName); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.id")).toBeTruthy(); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.arguments")).toBe( - `{"value":"${marker}"}` - ); - expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.result")).toBe(marker); - }); + ).toBe(true); + expect( + chatSpans.some((span) => + (getStringAttribute(span, "gen_ai.output.messages") ?? "").includes( + "TELEMETRY_E2E_DONE" + ) + ) + ).toBe(true); + + const toolSpan = spans.find( + (span) => getStringAttribute(span, "gen_ai.operation.name") === "execute_tool" + ); + expect(toolSpan).toBeDefined(); + expect(toolSpan!.parentSpanId).toBe(invokeAgentSpanId); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.name")).toBe(toolName); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.id")).toBeTruthy(); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.arguments")).toBe( + `{"value":"${marker}"}` + ); + expect(getStringAttribute(toolSpan!, "gen_ai.tool.call.result")).toBe(marker); + } + ); }); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fb7b66e19..969bb1aaf 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -433,6 +433,7 @@ dependencies = [ "futures-util", "getrandom 0.2.17", "http", + "libloading", "parking_lot", "regex", "reqwest", @@ -773,6 +774,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.16" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 66ef69ad2..078bea1f1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -48,6 +48,7 @@ tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", default-features = false } tracing = "0.1" dirs = "5" +libloading = "0.8" parking_lot = "0.12" regex = "1" getrandom = "0.2" diff --git a/rust/build.rs b/rust/build.rs index 66d1de7bc..3a26070dc 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -285,6 +285,21 @@ struct Platform { binary_name: &'static str, } +impl Platform { + /// Natural platform shared-library file name for the FFI runtime cdylib — + /// the tarball's `runtime.node` renamed to what the Rust cdylib would be + /// called on this OS. Derived from the asset name's platform token. + fn runtime_library_name(&self) -> &'static str { + if self.asset_name.contains("win32") { + "copilot_runtime.dll" + } else if self.asset_name.contains("darwin") { + "libcopilot_runtime.dylib" + } else { + "libcopilot_runtime.so" + } + } +} + fn target_platform() -> Option { let os = std::env::var("CARGO_CFG_TARGET_OS").ok()?; let arch = std::env::var("CARGO_CFG_TARGET_ARCH").ok()?; @@ -432,9 +447,101 @@ fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> P final_path.display() ); + // Best-effort: extract the in-process FFI runtime library next to the CLI, + // renamed from the tarball's `prebuilds//runtime.node` to the + // natural platform shared-library name (mirrors the .NET `.targets` and the + // bundled-CLI runtime extraction). Absence is not fatal — stdio/TCP + // transports don't need it, and `Transport::InProcess` surfaces a clear + // error at load time if it's missing. + extract_runtime_library_to_cache(archive, install_dir, platform); + final_path } +/// Write the tarball's `runtime.node` (the FFI cdylib) next to the extracted +/// CLI under the natural platform shared-library name. Best-effort: warns and +/// returns on any failure or when the archive doesn't ship it. +fn extract_runtime_library_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) { + let target = install_dir.join(platform.runtime_library_name()); + if std::fs::metadata(&target) + .map(|m| m.len() > 0) + .unwrap_or(false) + { + return; + } + let Some(bytes) = extract_runtime_library_bytes(archive, platform) else { + // The current GitHub-release tarball ships only the CLI binary; the FFI + // cdylib lives in the npm package layout. Absence is expected — stay + // quiet on the normal path (visible only with `-vv`). + println!( + "Archive `{}` has no runtime.node; Transport::InProcess needs COPILOT_CLI_PATH with a sibling runtime library", + platform.asset_name + ); + return; + }; + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let staging_path = install_dir.join(format!( + ".{}.staging-{}-{nanos}", + platform.runtime_library_name(), + std::process::id(), + )); + if let Err(e) = std::fs::write(&staging_path, &bytes) { + let _ = std::fs::remove_file(&staging_path); + println!( + "cargo:warning=Failed to stage runtime library {}: {e}", + staging_path.display() + ); + return; + } + if let Err(e) = std::fs::rename(&staging_path, &target) { + let _ = std::fs::remove_file(&staging_path); + println!( + "cargo:warning=Failed to place runtime library {}: {e}", + target.display() + ); + return; + } + println!( + "cargo:warning=Extracted in-process runtime library to {}", + target.display() + ); +} + +/// Extract the archive's `prebuilds//runtime.node` bytes, matched by +/// the `runtime.node` filename. Returns `None` when the archive doesn't ship it. +fn extract_runtime_library_bytes(archive: &[u8], platform: Platform) -> Option> { + if platform.asset_name.ends_with(".zip") { + let cursor = std::io::Cursor::new(archive); + let mut zip = zip::ZipArchive::new(cursor).ok()?; + for i in 0..zip.len() { + let mut entry = zip.by_index(i).ok()?; + let name = entry.name().to_string(); + if name == "runtime.node" || name.ends_with("/runtime.node") { + let mut bytes = Vec::with_capacity(entry.size() as usize); + std::io::copy(&mut entry, &mut bytes).ok()?; + return Some(bytes); + } + } + } else { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar.entries().ok()? { + let mut entry = entry.ok()?; + let name = entry.path().ok()?.to_string_lossy().into_owned(); + if name == "runtime.node" || name.ends_with("/runtime.node") { + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut bytes).ok()?; + return Some(bytes); + } + } + } + None +} + /// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version /// string is always safe to use as a path component. Kept in sync with /// `embeddedcli::sanitize_version` and `resolve::sanitize_version` so all diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index 56f97e0c0..6815a4428 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -41,7 +41,7 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(has_bundled_cli)] -use tracing::{info, warn}; +use tracing::{debug, info, warn}; // When the `bundled-cli` cargo feature is enabled and the target platform is // supported, build.rs generates `bundled_cli.rs` exposing the raw archive @@ -157,8 +157,64 @@ fn default_install_dir(version: &str) -> PathBuf { #[cfg(has_bundled_cli)] const MAX_PUBLISH_ATTEMPTS: u32 = 3; +// Natural platform shared-library name for the in-process FFI runtime cdylib — +// the tarball's `prebuilds//runtime.node` renamed to what the Rust +// cdylib would be called on this OS. Extracted next to the CLI so +// `Transport::InProcess` can load it without a separate download. +#[cfg(all(has_bundled_cli, windows))] +const RUNTIME_LIBRARY_NAME: &str = "copilot_runtime.dll"; +#[cfg(all(has_bundled_cli, target_os = "macos"))] +const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.dylib"; +#[cfg(all(has_bundled_cli, not(windows), not(target_os = "macos")))] +const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.so"; + +/// Install the CLI binary and, best-effort, the sibling in-process FFI runtime +/// library. The runtime library is optional and ships only in archive layouts +/// that include `prebuilds//runtime.node` (the npm package layout); +/// the GitHub-release CLI tarball this build downloads currently ships only the +/// CLI binary, so its absence is expected and non-fatal — stdio/TCP transports +/// don't need it and `Transport::InProcess` reports a clear error at load time. #[cfg(has_bundled_cli)] fn install(install_dir: &Path, archive: &[u8]) -> Result { + let final_path = install_cli(install_dir, archive)?; + if let Err(e) = install_runtime_library(install_dir, archive) { + warn!(error = %e, "failed to publish in-process FFI runtime library"); + } + Ok(final_path) +} + +/// Extract the archive's `runtime.node` (the FFI cdylib) and publish it next to +/// the CLI under the natural platform shared-library name. Idempotent — skips +/// when a non-empty library is already present. When the archive doesn't ship a +/// `runtime.node`, this is a no-op (logged at debug), since the current +/// GitHub-release tarball layout omits it. +#[cfg(has_bundled_cli)] +fn install_runtime_library(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { + let target = install_dir.join(RUNTIME_LIBRARY_NAME); + if fs::metadata(&target).map(|m| m.len() > 0).unwrap_or(false) { + return Ok(()); + } + let bytes = match extract_binary(archive, "runtime.node") { + Ok(bytes) if !bytes.is_empty() => bytes, + _ => { + debug!( + "bundled archive has no runtime.node; Transport::InProcess requires COPILOT_CLI_PATH \ + pointing at a CLI with a sibling runtime library" + ); + return Ok(()); + } + }; + let tmp = write_temp_file(install_dir, &bytes)?; + if let Err(e) = publish(&tmp, &target) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + debug!(path = %target.display(), "in-process FFI runtime library installed"); + Ok(()) +} + +#[cfg(has_bundled_cli)] +fn install_cli(install_dir: &Path, archive: &[u8]) -> Result { let verbose = std::env::var("COPILOT_CLI_INSTALL_VERBOSE").ok().as_deref() == Some("1"); fs::create_dir_all(install_dir) diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs new file mode 100644 index 000000000..257b6b72d --- /dev/null +++ b/rust/src/ffi.rs @@ -0,0 +1,465 @@ +//! In-process FFI transport: hosts the Copilot runtime in-process by loading +//! the runtime cdylib (`runtime.node`) and speaking JSON-RPC over its C ABI, +//! instead of spawning a CLI child process and communicating over stdio/TCP. +//! +//! The runtime's `host_start` export spawns the residual TypeScript worker +//! itself — the packaged single-file CLI (`copilot --embedded-host`) or, for +//! dev, `node dist-cli/index.js --embedded-host`. JSON-RPC frames are pumped +//! across the ABI: writes go to `connection_write`; inbound frames arrive on a +//! native callback that feeds an async reader. The framing is unchanged — the +//! same LSP `Content-Length:` frames the stdio transport uses. + +use std::ffi::c_void; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering}; +use std::task::{Context, Poll}; + +use libloading::Library; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::mpsc; +use tracing::debug; + +use crate::{Error, ErrorKind}; + +type OutboundCallback = unsafe extern "C" fn(*mut c_void, *const u8, usize); +type HostStartFn = unsafe extern "C" fn(*const u8, usize, *const u8, usize) -> u32; +type HostShutdownFn = unsafe extern "C" fn(u32) -> bool; +#[allow(clippy::type_complexity)] +type ConnectionOpenFn = unsafe extern "C" fn( + u32, + OutboundCallback, + *mut c_void, + *const u8, + usize, + *const u8, + usize, + *const u8, + usize, +) -> u32; +type ConnectionWriteFn = unsafe extern "C" fn(u32, *const u8, usize) -> bool; +type ConnectionCloseFn = unsafe extern "C" fn(u32) -> bool; + +/// State handed to the native side as `user_data` so the outbound callback can +/// route inbound frames back to the reader. +struct CallbackState { + tx: mpsc::UnboundedSender>, +} + +extern "C" fn on_outbound(user_data: *mut c_void, bytes: *const u8, len: usize) { + if user_data.is_null() || bytes.is_null() || len == 0 { + return; + } + let state = unsafe { &*(user_data as *const CallbackState) }; + let slice = unsafe { std::slice::from_raw_parts(bytes, len) }; + let _ = state.tx.send(slice.to_vec()); +} + +/// Loaded library, bound exports, and connection lifecycle state, shared +/// between the [`FfiWriter`] and the owning [`Client`]. Kept alive for the +/// connection's lifetime; dropping it unloads the cdylib. +pub(crate) struct FfiShared { + _lib: Library, + host_shutdown: HostShutdownFn, + connection_write: ConnectionWriteFn, + connection_close: ConnectionCloseFn, + server_id: AtomicU32, + connection_id: AtomicU32, + callback_state: AtomicPtr, + closed: AtomicBool, + library_path: PathBuf, +} + +// The raw fn pointers and the boxed callback state are safe to move across +// threads: the native side copies buffers synchronously and the callback only +// forwards to a thread-safe channel sender. +unsafe impl Send for FfiShared {} +unsafe impl Sync for FfiShared {} + +impl FfiShared { + /// Close the connection, shut the host down, and free the callback state. + /// Idempotent; called from [`Client::stop`], drop, and on startup failure. + pub(crate) fn close(&self) { + if self.closed.swap(true, Ordering::SeqCst) { + return; + } + let conn = self.connection_id.swap(0, Ordering::SeqCst); + if conn != 0 { + unsafe { (self.connection_close)(conn) }; + } + let server = self.server_id.swap(0, Ordering::SeqCst); + if server != 0 { + unsafe { (self.host_shutdown)(server) }; + } + // Free the callback state only after the connection is closed and the + // host is shut down, so native can no longer invoke the callback. + let state = self + .callback_state + .swap(std::ptr::null_mut(), Ordering::SeqCst); + if !state.is_null() { + drop(unsafe { Box::from_raw(state) }); + } + debug!(library = %self.library_path.display(), "FFI runtime connection closed"); + } + + fn write_frame(&self, frame: &[u8]) -> bool { + if self.closed.load(Ordering::SeqCst) { + return false; + } + let conn = self.connection_id.load(Ordering::SeqCst); + if conn == 0 { + return false; + } + unsafe { (self.connection_write)(conn, frame.as_ptr(), frame.len()) } + } +} + +impl Drop for FfiShared { + fn drop(&mut self) { + self.close(); + } +} + +/// Read side of the FFI transport, fed by the native outbound callback via an +/// unbounded channel. Implements [`AsyncRead`] for the JSON-RPC read loop. +pub(crate) struct FfiReader { + rx: mpsc::UnboundedReceiver>, + leftover: Vec, + pos: usize, +} + +impl AsyncRead for FfiReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.pos >= self.leftover.len() { + match self.rx.poll_recv(cx) { + Poll::Ready(Some(chunk)) => { + self.leftover = chunk; + self.pos = 0; + } + Poll::Ready(None) => return Poll::Ready(Ok(())), + Poll::Pending => return Poll::Pending, + } + } + let available = self.leftover.len() - self.pos; + let n = available.min(buf.remaining()); + let start = self.pos; + buf.put_slice(&self.leftover[start..start + n]); + self.pos += n; + Poll::Ready(Ok(())) + } +} + +/// Write side of the FFI transport. Each frame is forwarded synchronously to +/// the native `connection_write` export (native copies before returning). +pub(crate) struct FfiWriter { + shared: Arc, +} + +impl AsyncWrite for FfiWriter { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.shared.write_frame(buf) { + Poll::Ready(Ok(buf.len())) + } else { + Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "failed to write a frame to the in-process runtime connection", + ))) + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +/// Prepared FFI host: the loaded cdylib plus the spawn arguments needed to +/// start the runtime worker. +pub(crate) struct FfiHost { + lib: Library, + library_path: PathBuf, + entrypoint: PathBuf, + environment: Vec<(String, String)>, + host_start: HostStartFn, + host_shutdown: HostShutdownFn, + connection_open: ConnectionOpenFn, + connection_write: ConnectionWriteFn, + connection_close: ConnectionCloseFn, +} + +// SAFETY: as for `FfiShared` — the bound exports are plain fn pointers and the +// library handle is safe to move to the blocking thread that starts the host. +unsafe impl Send for FfiHost {} + +impl FfiHost { + /// Load the cdylib next to `entrypoint` and bind its exports. + /// + /// `entrypoint` is the packaged single-file CLI binary or, for dev, a + /// `.js` file launched via `node`. The cdylib is resolved relative to the + /// entrypoint directory: first the flat natural shared-library name, then + /// the dev/tarball `prebuilds/-/runtime.node` layout. + pub(crate) fn create( + entrypoint: &Path, + environment: Vec<(String, String)>, + ) -> Result { + let library_path = resolve_library_path(entrypoint)?; + let lib = unsafe { Library::new(&library_path) }.map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "failed to load in-process runtime library '{}': {e}", + library_path.display() + ), + ) + })?; + + let host_start = + *bind::(&lib, b"copilot_runtime_host_start\0", &library_path)?; + let host_shutdown = + *bind::(&lib, b"copilot_runtime_host_shutdown\0", &library_path)?; + let connection_open = + *bind::(&lib, b"copilot_runtime_connection_open\0", &library_path)?; + let connection_write = + *bind::(&lib, b"copilot_runtime_connection_write\0", &library_path)?; + let connection_close = + *bind::(&lib, b"copilot_runtime_connection_close\0", &library_path)?; + + Ok(Self { + lib, + library_path, + entrypoint: entrypoint.to_path_buf(), + environment, + host_start, + host_shutdown, + connection_open, + connection_write, + connection_close, + }) + } + + /// Start the runtime worker and open the FFI JSON-RPC connection. + /// + /// `host_start` blocks until the worker connects back and signals + /// readiness (up to ~30s), and must not run on an async executor thread, so + /// the blocking handshake is offloaded to [`tokio::task::spawn_blocking`]. + pub(crate) async fn start(self) -> Result<(FfiReader, FfiWriter, Arc), Error> { + tokio::task::spawn_blocking(move || self.start_blocking()) + .await + .map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!("in-process runtime startup task failed: {e}"), + ) + })? + } + + fn start_blocking(self) -> Result<(FfiReader, FfiWriter, Arc), Error> { + let argv = build_argv_json(&self.entrypoint); + let env = build_env_json(&self.environment); + + let (env_ptr, env_len) = match &env { + Some(bytes) => (bytes.as_ptr(), bytes.len()), + None => (std::ptr::null(), 0), + }; + let server_id = unsafe { (self.host_start)(argv.as_ptr(), argv.len(), env_ptr, env_len) }; + if server_id == 0 { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + format!( + "copilot_runtime_host_start failed (library '{}', entrypoint '{}')", + self.library_path.display(), + self.entrypoint.display() + ), + )); + } + + let (tx, rx) = mpsc::unbounded_channel::>(); + let state_ptr = Box::into_raw(Box::new(CallbackState { tx })); + let connection_id = unsafe { + (self.connection_open)( + server_id, + on_outbound, + state_ptr as *mut c_void, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ) + }; + if connection_id == 0 { + drop(unsafe { Box::from_raw(state_ptr) }); + unsafe { (self.host_shutdown)(server_id) }; + return Err(Error::with_message( + ErrorKind::InvalidConfig, + "copilot_runtime_connection_open failed", + )); + } + + let shared = Arc::new(FfiShared { + _lib: self.lib, + host_shutdown: self.host_shutdown, + connection_write: self.connection_write, + connection_close: self.connection_close, + server_id: AtomicU32::new(server_id), + connection_id: AtomicU32::new(connection_id), + callback_state: AtomicPtr::new(state_ptr), + closed: AtomicBool::new(false), + library_path: self.library_path.clone(), + }); + + debug!( + library = %self.library_path.display(), + server_id, connection_id, "FFI runtime host started" + ); + + let reader = FfiReader { + rx, + leftover: Vec::new(), + pos: 0, + }; + let writer = FfiWriter { + shared: Arc::clone(&shared), + }; + Ok((reader, writer, shared)) + } +} + +fn bind<'lib, T>( + lib: &'lib Library, + symbol: &[u8], + library_path: &Path, +) -> Result, Error> { + unsafe { lib.get::(symbol) }.map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "in-process runtime library '{}' is missing an expected export ({}): {e}", + library_path.display(), + String::from_utf8_lossy(symbol.strip_suffix(b"\0").unwrap_or(symbol)) + ), + ) + }) +} + +/// The natural platform shared-library file name for the runtime cdylib — the +/// `.node` file renamed to what the Rust cdylib would be called on this OS. +fn natural_library_name() -> &'static str { + if cfg!(windows) { + "copilot_runtime.dll" + } else if cfg!(target_os = "macos") { + "libcopilot_runtime.dylib" + } else { + "libcopilot_runtime.so" + } +} + +/// The napi-rs prebuilds folder name for the current host — the +/// `-` convention (e.g. `win32-x64`, `darwin-arm64`, +/// `linux-x64`) under which the runtime ships `prebuilds//runtime.node`. +pub(crate) fn prebuilds_folder() -> Option { + let platform = if cfg!(target_os = "windows") { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else { + return None; + }; + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + return None; + }; + Some(format!("{platform}-{arch}")) +} + +fn resolve_library_path(entrypoint: &Path) -> Result { + let dir = entrypoint.parent().ok_or_else(|| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "could not determine directory for CLI entrypoint '{}'", + entrypoint.display() + ), + ) + })?; + + // Bundled/flat layout: natural shared-library name next to the CLI. + let flat = dir.join(natural_library_name()); + if flat.is_file() { + return Ok(flat); + } + + // Dev/tarball layout: prebuilds/-/runtime.node. + let prebuilds = + prebuilds_folder().map(|folder| dir.join("prebuilds").join(folder).join("runtime.node")); + if let Some(prebuilds_path) = &prebuilds + && prebuilds_path.is_file() + { + return Ok(prebuilds_path.clone()); + } + + let searched = match &prebuilds { + Some(p) => format!("'{}' and '{}'", flat.display(), p.display()), + None => format!("'{}'", flat.display()), + }; + Err(Error::with_message( + ErrorKind::BinaryNotFound { + name: "runtime.node".into(), + hint: Some(format!( + "in-process runtime library not found; looked for {searched}. Ensure the \ + bundled CLI's sibling runtime library is present or set COPILOT_CLI_PATH to a \ + CLI whose directory contains it." + )), + }, + "in-process runtime library not found", + )) +} + +fn build_argv_json(entrypoint: &Path) -> Vec { + // A `.js` entrypoint (dev / dist-cli) is launched via node; the packaged + // single-file CLI binary embeds its own Node and is invoked directly. + let entrypoint_str = entrypoint.to_string_lossy().into_owned(); + let is_js = entrypoint + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("js")); + let argv: Vec = if is_js { + vec![ + "node".to_string(), + entrypoint_str, + "--embedded-host".to_string(), + ] + } else { + vec![entrypoint_str, "--embedded-host".to_string()] + }; + serde_json::to_vec(&argv).expect("argv serializes") +} + +fn build_env_json(environment: &[(String, String)]) -> Option> { + if environment.is_empty() { + return None; + } + let map: serde_json::Map = environment + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(); + Some(serde_json::to_vec(&map).expect("env serializes")) +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 0333281f5..ca83d350e 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -10,6 +10,8 @@ mod canvas_dispatch; #[cfg(feature = "bundled-cli")] pub(crate) mod embeddedcli; mod errors; +/// In-process FFI transport hosting the runtime cdylib (`Transport::InProcess`). +pub(crate) mod ffi; pub use errors::*; /// Connection-level Copilot request handler — intercept and replace the /// model-layer HTTP and WebSocket traffic the runtime issues for both CAPI and @@ -112,6 +114,22 @@ pub enum Transport { /// Communicate over stdin/stdout pipes (default). #[default] Stdio, + /// Host the runtime in-process over FFI (no child process). + /// + /// Loads the runtime cdylib (`runtime.node`) next to the resolved CLI + /// entrypoint and speaks JSON-RPC over its C ABI. The runtime spawns its + /// own worker; the SDK never launches a CLI child process. This is the + /// Rust analogue of the .NET `RuntimeConnection.ForInProcess()`. + /// + /// **Experimental.** Per-client options that lower to environment variables + /// — [`ClientOptions::env`]/[`ClientOptions::env_remove`], + /// [`ClientOptions::telemetry`], [`ClientOptions::github_token`], and + /// [`ClientOptions::base_directory`] — are **not** honored with this + /// transport: the runtime loads into the shared host process and its worker + /// inherits that process's ambient environment. Configure the runtime via + /// the host process environment instead. See + /// . + InProcess, /// Spawn the CLI with `--port` and connect via TCP. Tcp { /// Port to listen on (0 for OS-assigned). @@ -850,6 +868,38 @@ fn generate_connection_token() -> String { hex } +/// Environment variable that overrides the transport used when the caller +/// leaves [`ClientOptions::transport`] at its default ([`Transport::Stdio`]). +/// Accepts `"inprocess"` or `"stdio"` (case-insensitive); unset preserves +/// stdio. Any other value is an error. Mirrors the .NET +/// `COPILOT_SDK_DEFAULT_CONNECTION` handling. +const DEFAULT_CONNECTION_ENV_VAR: &str = "COPILOT_SDK_DEFAULT_CONNECTION"; + +/// Resolve a transport override from [`DEFAULT_CONNECTION_ENV_VAR`], checking +/// [`ClientOptions::env`] first, then the process environment. Returns +/// `Ok(None)` when the override is absent or selects stdio. +fn resolve_default_transport(options: &ClientOptions) -> Result> { + let value = options + .env + .iter() + .find(|(key, _)| key.as_os_str() == std::ffi::OsStr::new(DEFAULT_CONNECTION_ENV_VAR)) + .map(|(_, value)| value.to_string_lossy().into_owned()) + .or_else(|| std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok()); + + match value.as_deref() { + None => Ok(None), + Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(None), + Some(v) if v.eq_ignore_ascii_case("inprocess") => Ok(Some(Transport::InProcess)), + Some(v) => Err(Error::with_message( + ErrorKind::InvalidConfig, + format!( + "invalid {DEFAULT_CONNECTION_ENV_VAR} value '{v}'. \ + Expected 'inprocess', 'stdio', or unset." + ), + )), + } +} + /// Connection to a GitHub Copilot CLI server (stdio, TCP, or external). /// /// Cheaply cloneable — cloning shares the underlying connection. @@ -870,6 +920,9 @@ impl std::fmt::Debug for Client { struct ClientInner { child: parking_lot::Mutex>, + /// In-process FFI runtime host, set only for [`Transport::InProcess`]. + /// Closing it tears down the FFI connection and unloads the cdylib. + ffi_host: parking_lot::Mutex>>, rpc: JsonRpcClient, cwd: PathBuf, request_rx: parking_lot::Mutex>>, @@ -929,6 +982,16 @@ impl Client { if let Some(cfg) = &options.session_fs { validate_session_fs_config(cfg)?; } + let mut options = options; + // Honor COPILOT_SDK_DEFAULT_CONNECTION when the caller leaves the + // transport at its default (stdio). Mirrors the .NET + // `ResolveDefaultConnection`: `inprocess` selects in-process FFI + // hosting, `stdio`/unset keeps stdio, anything else is an error. + if matches!(options.transport, Transport::Stdio) + && let Some(resolved) = resolve_default_transport(&options)? + { + options.transport = resolved; + } // 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 { .. }) { @@ -970,9 +1033,8 @@ impl Client { // to the server. For Tcp, the SDK auto-generates one when the // caller leaves it unset so the loopback listener is safe by // default. - let mut options = options; let effective_connection_token: Option = match &mut options.transport { - Transport::Stdio => None, + Transport::Stdio | Transport::InProcess => None, Transport::Tcp { connection_token, .. } => Some( @@ -1094,8 +1156,34 @@ impl Client { options.mode, )? } + Transport::InProcess => { + // Per-client options that lower to environment variables (env, + // telemetry, github_token, base_directory) are not honored over the + // in-process transport: the runtime loads into this shared host + // process and its worker inherits this process's ambient environment, + // which a single env block cannot vary per client. Pass no per-client + // env; configure the runtime via the host process environment instead. + // See https://github.com/github/copilot-sdk/issues/1934. + info!(entrypoint = %program.display(), "hosting copilot runtime in-process (FFI)"); + let host = crate::ffi::FfiHost::create(&program, Vec::new())?; + let (reader, writer, shared) = host.start().await?; + let client = Self::from_transport( + reader, + writer, + None, + options.working_directory, + options.on_list_models, + session_fs_config.is_some(), + session_fs_sqlite_declared, + options.on_get_trace_context, + options.on_github_telemetry, + effective_connection_token.clone(), + options.mode, + )?; + *client.inner.ffi_host.lock() = Some(shared); + client + } }; - debug!( elapsed_ms = start_time.elapsed().as_millis(), "Client::start transport setup complete" @@ -1294,6 +1382,7 @@ impl Client { let client = Self { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(child), + ffi_host: parking_lot::Mutex::new(None), rpc, cwd, request_rx: parking_lot::Mutex::new(Some(request_rx)), @@ -2063,7 +2152,8 @@ impl Client { self.inner.router.unregister(&session_id); } - let should_shutdown_runtime = self.inner.child.lock().is_some(); + let should_shutdown_runtime = + self.inner.child.lock().is_some() || self.inner.ffi_host.lock().is_some(); if should_shutdown_runtime { let runtime_shutdown_start = Instant::now(); match tokio::time::timeout(RUNTIME_SHUTDOWN_TIMEOUT, self.rpc().runtime().shutdown()) @@ -2120,6 +2210,13 @@ impl Client { } } + // In-process FFI host: close the connection, shut the host down, and + // unload the cdylib. The runtime.shutdown RPC above already asked the + // worker to clean up; closing here tears down the transport. + if let Some(host) = self.inner.ffi_host.lock().take() { + host.close(); + } + info!(pid = ?pid, errors = errors.len(), "CLI process stopped"); if errors.is_empty() { Ok(()) @@ -2165,6 +2262,9 @@ impl Client { { error!(pid = ?pid, error = %e, "failed to send kill signal"); } + if let Some(host) = self.inner.ffi_host.lock().take() { + host.close(); + } self.inner.rpc.force_close(); // Drop all session channels so any awaiters see a closed channel // instead of waiting for responses that will never arrive. @@ -2222,6 +2322,9 @@ impl Drop for ClientInner { info!(pid = ?pid, "kill signal sent for CLI process on drop"); } } + if let Some(host) = self.ffi_host.lock().take() { + host.close(); + } } } @@ -2798,6 +2901,7 @@ mod tests { Client { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(None), + ffi_host: parking_lot::Mutex::new(None), rpc: { let (req_tx, _req_rx) = mpsc::unbounded_channel(); let (notif_tx, _notif_rx) = broadcast::channel(16); diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 62412963b..c60d09517 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -37,6 +37,8 @@ mod github_telemetry; mod hooks; #[path = "e2e/hooks_extended.rs"] mod hooks_extended; +#[path = "e2e/inprocess.rs"] +mod inprocess; #[path = "e2e/mcp_and_agents.rs"] mod mcp_and_agents; #[path = "e2e/mcp_oauth.rs"] diff --git a/rust/tests/e2e/inprocess.rs b/rust/tests/e2e/inprocess.rs new file mode 100644 index 000000000..bc9d6c855 --- /dev/null +++ b/rust/tests/e2e/inprocess.rs @@ -0,0 +1,27 @@ +use super::support::with_e2e_context; + +/// Mirrors the .NET `Should_Start_And_Connect_Over_InProcess_Ffi`: start a +/// client that hosts the runtime in-process over FFI, perform a simple +/// round-trip, and stop cleanly. Fails hard (does not skip) if the in-process +/// runtime library can't be loaded. +#[tokio::test] +async fn should_start_ping_and_stop_inprocess_client() { + with_e2e_context("client", "should_start_ping_and_stop_stdio_client", |ctx| { + Box::pin(async move { + let client = ctx.start_inprocess_client().await; + + let response = client + .ping(Some("hello from rust in-process")) + .await + .expect("ping over in-process FFI transport"); + assert_eq!(response.message, "pong: hello from rust in-process"); + assert!(!response.timestamp.is_empty()); + + let status = client.get_status().await.expect("get status"); + assert!(status.protocol_version > 0); + + client.stop().await.expect("stop in-process client"); + }) + }) + .await; +} diff --git a/rust/tests/e2e/rpc_workspace_checkpoints.rs b/rust/tests/e2e/rpc_workspace_checkpoints.rs index 2c185a535..0a8bf5615 100644 --- a/rust/tests/e2e/rpc_workspace_checkpoints.rs +++ b/rust/tests/e2e/rpc_workspace_checkpoints.rs @@ -40,6 +40,12 @@ async fn should_list_no_checkpoints_for_fresh_session() { #[tokio::test] async fn should_return_null_or_empty_content_for_unknown_checkpoint() { + // In-process, session.workspaces.readCheckpoint is answered by the native runtime, + // which decodes the checkpoint number as a u32 and rejects the i64::MAX sentinel this + // test uses. Covered by the default (stdio) transport. See issue #1934. + if super::support::skip_inprocess("readCheckpoint decodes the id as u32 in-process") { + return; + } with_e2e_context( "rpc_workspace_checkpoints", "should_return_null_or_empty_content_for_unknown_checkpoint", diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 7e47d8fba..f5c844516 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -36,6 +36,13 @@ where .await .unwrap_or_else(|err| panic!("create E2E context: {err}")); + // In-process hosting: the runtime loads into this test process and its worker + // inherits the ambient environment (per-client env is not honored in-process, see + // https://github.com/github/copilot-sdk/issues/1934), so mirror this context's env + // onto the process for the duration of the test and restore on drop. Safe because + // E2E_CONCURRENCY is 1 in-process, serializing the whole critical section. + let _env_guard = InProcessEnvGuard::activate(&ctx); + let timed_out = tokio::time::timeout(default_test_timeout(), test(&mut ctx)) .await .is_err(); @@ -65,6 +72,10 @@ where .await .unwrap_or_else(|err| panic!("create E2E context: {err}")); + // See `with_e2e_context` for why the in-process transport mirrors env onto the + // process (restored on drop). + let _env_guard = InProcessEnvGuard::activate(&ctx); + let timed_out = tokio::time::timeout(default_test_timeout(), test(&mut ctx)) .await .is_err(); @@ -170,6 +181,24 @@ impl E2eContext { .expect("start E2E client") } + /// Start a client that hosts the runtime in-process over FFI + /// ([`Transport::InProcess`]). Unlike the stdio harness, the CLI + /// entrypoint is passed as the program directly (the FFI host builds the + /// `node --embedded-host` argv itself and loads the sibling + /// runtime cdylib), so a `.js` entrypoint is not split into node + + /// prefix_args here. + pub async fn start_inprocess_client(&self) -> Client { + let options = ClientOptions::new() + .with_cwd(self.work_dir.path()) + .with_env(self.environment()) + .with_use_logged_in_user(false) + .with_program(CliProgram::Path(self.cli_path.clone())) + .with_transport(Transport::InProcess); + Client::start(options) + .await + .expect("start in-process FFI E2E client") + } + /// Start a client wired to a Copilot request handler, appending `extra_env` /// to the spawned runtime's environment (used to flip the WebSocket ExP /// flag for the WS transport tests). @@ -528,6 +557,12 @@ fn default_test_timeout() -> Duration { } fn e2e_concurrency() -> usize { + // The in-process transport mirrors per-test environment onto the shared process + // environment (see `InProcessEnvGuard`), which is only coherent when one test runs + // at a time. Force serial execution in-process; otherwise honor RUST_E2E_CONCURRENCY. + if is_inprocess_default() { + return 1; + } std::env::var("RUST_E2E_CONCURRENCY") .ok() .and_then(|value| value.parse::().ok()) @@ -535,6 +570,76 @@ fn e2e_concurrency() -> usize { .unwrap_or(4) } +/// True when the E2E suite runs over the in-process (FFI) transport, i.e. the SDK +/// resolves `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to [`Transport::InProcess`]. +pub fn is_inprocess_default() -> bool { + std::env::var("COPILOT_SDK_DEFAULT_CONNECTION") + .map(|value| value.eq_ignore_ascii_case("inprocess")) + .unwrap_or(false) +} + +/// Skip guard for E2E tests exercising features the in-process (FFI) transport does not +/// support (the runtime loads into the shared host process). Returns `true` — and logs — +/// when running in-process so the caller can `return` early; such tests remain covered +/// by the default (stdio) transport. See . +pub fn skip_inprocess(reason: &str) -> bool { + if is_inprocess_default() { + eprintln!("skipping test over the in-process (FFI) transport: {reason}"); + true + } else { + false + } +} + +/// Mirrors an [`E2eContext`]'s environment onto the real process environment for the +/// in-process transport, whose worker inherits this process's ambient environment +/// rather than a per-client env block. Restores the previous values on drop. Only the +/// in-process transport needs this; for stdio/tcp the environment is handed to the +/// spawned child directly. Auth flows via GH_TOKEN/GITHUB_TOKEN and HMAC is disabled so +/// host-side auth resolution picks the token the replay snapshots expect. +struct InProcessEnvGuard { + saved: Vec<(OsString, Option)>, +} + +impl InProcessEnvGuard { + /// Returns `Some` guard (having applied the env) when in-process, else `None`. + fn activate(ctx: &E2eContext) -> Option { + if !is_inprocess_default() { + return None; + } + let mut pairs: Vec<(OsString, OsString)> = ctx.environment(); + pairs.push(("GH_TOKEN".into(), "fake-token-for-e2e-tests".into())); + pairs.push(("GITHUB_TOKEN".into(), "fake-token-for-e2e-tests".into())); + + let mut saved: Vec<(OsString, Option)> = Vec::new(); + for (key, value) in &pairs { + saved.push((key.clone(), std::env::var_os(key))); + // SAFETY: the E2E suite runs serially in-process (concurrency 1), so no + // other thread races these process-wide env mutations. + unsafe { std::env::set_var(key, value) }; + } + for key in ["COPILOT_HMAC_KEY", "CAPI_HMAC_KEY"] { + let key = OsString::from(key); + saved.push((key.clone(), std::env::var_os(&key))); + // SAFETY: as above. + unsafe { std::env::remove_var(&key) }; + } + Some(Self { saved }) + } +} + +impl Drop for InProcessEnvGuard { + fn drop(&mut self) { + for (key, previous) in self.saved.iter().rev() { + // SAFETY: as in `activate` — serial execution in-process. + match previous { + Some(value) => unsafe { std::env::set_var(key, value) }, + None => unsafe { std::env::remove_var(key) }, + } + } + } +} + pub fn get_system_message(exchange: &serde_json::Value) -> String { exchange .get("request") @@ -626,6 +731,18 @@ fn client_options_for_cli( .with_cwd(cwd) .with_env(env) .with_use_logged_in_user(false); + // When the in-process FFI transport is the default (matrix cell that sets + // COPILOT_SDK_DEFAULT_CONNECTION=inprocess), pass the CLI entrypoint + // directly: the FFI host builds the `node --embedded-host` + // argv itself and loads the sibling runtime cdylib. Splitting a `.js` + // entrypoint into node + prefix_args (the stdio layout) would point the + // library resolver at node's directory instead. + let inprocess_default = std::env::var("COPILOT_SDK_DEFAULT_CONNECTION") + .map(|value| value.eq_ignore_ascii_case("inprocess")) + .unwrap_or(false); + if inprocess_default { + return options.with_program(CliProgram::Path(cli_path.to_path_buf())); + } if cli_path .extension() .and_then(|extension| extension.to_str()) diff --git a/rust/tests/e2e/telemetry.rs b/rust/tests/e2e/telemetry.rs index 38bf4a404..f6905427a 100644 --- a/rust/tests/e2e/telemetry.rs +++ b/rust/tests/e2e/telemetry.rs @@ -13,6 +13,11 @@ use super::support::{assistant_message_content, with_e2e_context}; #[tokio::test] async fn should_export_file_telemetry_for_sdk_interactions() { + // Telemetry lowers to environment variables the in-process worker cannot receive + // per-client; covered by the default (stdio) transport. See issue #1934. + if super::support::skip_inprocess("telemetry configuration is not honored in-process") { + return; + } with_e2e_context( "telemetry", "should_export_file_telemetry_for_sdk_interactions",