diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml
index f12f53bd96..9ce9d69108 100644
--- a/.github/workflows/dotnet-sdk-tests.yml
+++ b/.github/workflows/dotnet-sdk-tests.yml
@@ -56,20 +56,26 @@ jobs:
transport: ["default", "inprocess"]
backend: [capi]
shard: [full]
- # TODO: Re-enable after fixing in-process sqlite file locking on shutdown on Windows.
exclude:
- - os: windows-latest
- transport: "inprocess"
- os: windows-latest
transport: default
shard: full
# TODO(cli-1.0.81-2): CLI 1.0.81-5 still stops completing in-process
# CAPI model turns, causing repeated per-test timeouts until the
# 30-minute job limit. Stdio CAPI and in-process BYOK remain enabled.
+ # This affects every OS equally (it is a CLI/CAPI regression, not a
+ # platform-specific one), so Linux/macOS are excluded from the `capi`
+ # in-process cell here; see the ubuntu-latest/inprocess include cells
+ # further down for their in-process coverage via the alternate
+ # backends. windows-latest/inprocess has no in-process coverage at
+ # all right now (capi or otherwise) -- see the comment further down
+ # by the removed windows-latest/inprocess include cells for why.
- os: ubuntu-latest
transport: inprocess
- os: macos-latest
transport: inprocess
+ - os: windows-latest
+ transport: inprocess
# The macOS default/capi host runs the whole suite on the smallest
# runner in the matrix (3 vCPU / 7 GB vs ubuntu's 4 / 16). Since the
# 1.0.81-2 bump it stopped finishing: the job ran 50+ minutes until
@@ -182,11 +188,24 @@ jobs:
backend: openai-completions
shard: full
test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly"
+ # Windows in-process coverage was attempted here (github/copilot-sdk#2525):
+ # FfiRuntimeHost.Dispose() now bounds its wait on native shutdown (see below),
+ # which should have made this safe to enable now that napi-oop is gone. But
+ # actually running it on real Windows CI (github/copilot-sdk#2531) reproduced
+ # native `System.AccessViolationException` crashes in ConnectionWrite during
+ # ordinary connection I/O -- unrelated to shutdown/disposal, and independently
+ # matched by a SIGSEGV in the Rust SDK's own Windows in-process CI in the same
+ # PR. That rules out an SDK-side binding bug; tracked upstream at
+ # github/copilot-agent-runtime#18990. Retrying after rebasing onto CLI 1.0.84-4
+ # reproduced the same blocker, so re-add windows-latest here once resolved.
runs-on: ${{ matrix.os }}
# A hung test used to run until the runner died (~50 min) and the dying
# runner never uploaded its logs, so the failures were undiagnosable.
- # Every healthy cell finishes well under 15 min.
- timeout-minutes: 20
+ # Most healthy cells finish well under 15 min, but macOS shard 1 can run
+ # longer on the smallest runner after runtime/dependency updates. Keep the
+ # job bound high enough that slow-but-healthy shards aren't canceled without
+ # diagnostics.
+ timeout-minutes: 30
defaults:
run:
shell: bash
diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml
index 440641bbdc..c39e3b319f 100644
--- a/.github/workflows/rust-sdk-tests.yml
+++ b/.github/workflows/rust-sdk-tests.yml
@@ -20,7 +20,14 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
- timeout-minutes: 20
+ # The "cargo test" step below allows up to 90 minutes on its own
+ # (timeout-minutes: 90), but a *job*-level timeout still cancels the whole
+ # job (including checkout/toolchain/cache steps) once it elapses,
+ # regardless of any step-level timeout. It must stay >= the step timeout
+ # plus setup overhead, or a slow-but-healthy run (e.g. a cold Windows
+ # dependency compile) is killed as "canceled" before the step's own bound
+ # is ever reached. See github/copilot-sdk#2525.
+ timeout-minutes: 100
defaults:
run:
shell: bash
@@ -206,7 +213,21 @@ jobs:
strategy:
fail-fast: false
matrix:
- # TODO: Re-enable Windows after fixing the napi-oop peer shutdown crash.
+ # Windows was previously excluded here because of a napi-oop peer
+ # shutdown crash; the runtime no longer depends on a Node
+ # child/parent process (napi-oop is gone), so that specific failure
+ # mode no longer applies. However, actually running Windows in this
+ # job (github/copilot-sdk#2531) reproduced a *different*, still-open
+ # problem: real native `STATUS_ACCESS_VIOLATION` crashes (SIGSEGV)
+ # partway through the full E2E suite, with no single deterministic
+ # reproducer — consistent with native memory corruption in the
+ # shared runtime cdylib rather than anything fixable from this SDK's
+ # FFI bindings. An identical crash class (AccessViolationException)
+ # was independently reproduced on Windows in-process in the .NET SDK
+ # in the same PR, ruling out a per-language binding bug. Retrying
+ # after rebasing onto CLI 1.0.84-4 reproduced the same blocker.
+ # Tracked upstream at github/copilot-agent-runtime#18990; re-add
+ # windows-latest here once that's resolved. See github/copilot-sdk#2525.
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs
index f9f586a385..15c2257a23 100644
--- a/dotnet/src/FfiRuntimeHost.cs
+++ b/dotnet/src/FfiRuntimeHost.cs
@@ -45,9 +45,30 @@ private enum NativeCleanupResult
/// Logical name the native interop layer binds the cdylib to.
private const string LibraryName = "copilot_runtime";
private const int CleanupRetryDelayMilliseconds = 100;
+ private static readonly TimeSpan s_hostShutdownTimeout = TimeSpan.FromSeconds(10);
private static readonly object QuarantineLock = new();
private static readonly HashSet QuarantinedHosts = [];
+ ///
+ /// Serializes native host lifecycle transitions (host_start/connection_open
+ /// in against host_shutdown in )
+ /// process-wide.
+ ///
+ ///
+ /// Bounding 's wait (see ) means a
+ /// slow native shutdown can still be running on an abandoned background thread after
+ /// Dispose() has already returned to its caller. Observed on Windows in-process CI: a new
+ /// client's host_start/connection_open overlapping with a different client's still-draining
+ /// host_shutdown corrupted shared native state and crashed the process with an
+ /// AccessViolationException while writing the new connection's handshake frame (see
+ /// github/copilot-sdk#2525). This gate prevents that overlap: a new Start() waits for any
+ /// in-flight shutdown (abandoned or not) to actually finish before opening a new native
+ /// connection, while multiple already-started hosts remain free to run concurrently (the
+ /// gate is only held during the brief start/open and shutdown transitions, not for the
+ /// lifetime of a live connection).
+ ///
+ private static readonly SemaphoreSlim s_nativeLifecycleGate = new(1, 1);
+
private readonly ILogger _logger;
private readonly string? _cliEntrypoint;
private readonly string _libraryPath;
@@ -134,43 +155,54 @@ internal static string GetRuntimeLibraryFileName()
///
public async Task StartAsync(CancellationToken cancellationToken)
{
- // Keep synchronous native startup off the caller's async context.
- await Task.Run(() =>
+ // See s_nativeLifecycleGate: block a new host_start/connection_open until any
+ // other host's host_shutdown (including one Dispose() already stopped waiting
+ // on) has actually finished.
+ await s_nativeLifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
{
- var argvJson = BuildArgvJson(_cliEntrypoint, _args);
- var envJson = BuildEnvJson(_environment);
-
- var serverId = NativeHostStart(argvJson, envJson);
- if (serverId == 0)
+ // Keep synchronous native startup off the caller's async context.
+ await Task.Run(() =>
{
- throw new InvalidOperationException(
- $"copilot_runtime_host_start failed (library '{_libraryPath}').");
- }
+ var argvJson = BuildArgvJson(_cliEntrypoint, _args);
+ var envJson = BuildEnvJson(_environment);
- var connectionId = NativeOpenConnection(serverId);
- if (connectionId == 0)
- {
- _releaseNativeCallback();
- NativeHostShutdown(serverId);
- throw new InvalidOperationException("copilot_runtime_connection_open failed.");
- }
+ var serverId = NativeHostStart(argvJson, envJson);
+ if (serverId == 0)
+ {
+ throw new InvalidOperationException(
+ $"copilot_runtime_host_start failed (library '{_libraryPath}').");
+ }
- lock (_lifecycleLock)
- {
- _serverId = serverId;
- _connectionId = connectionId;
- _sendStream = new CallbackSendStream(SendFrame);
- if (_disposed)
+ var connectionId = NativeOpenConnection(serverId);
+ if (connectionId == 0)
{
- if (TryFinalizeNativeCleanup() == NativeCleanupResult.Retry)
+ _releaseNativeCallback();
+ NativeHostShutdown(serverId);
+ throw new InvalidOperationException("copilot_runtime_connection_open failed.");
+ }
+
+ lock (_lifecycleLock)
+ {
+ _serverId = serverId;
+ _connectionId = connectionId;
+ _sendStream = new CallbackSendStream(SendFrame);
+ if (_disposed)
{
- ScheduleNativeCleanupRetry();
+ if (TryFinalizeNativeCleanup() == NativeCleanupResult.Retry)
+ {
+ ScheduleNativeCleanupRetry();
+ }
+ throw new InvalidOperationException(
+ "FfiRuntimeHost was disposed during startup.");
}
- throw new InvalidOperationException(
- "FfiRuntimeHost was disposed during startup.");
}
- }
- }, cancellationToken).ConfigureAwait(false);
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ s_nativeLifecycleGate.Release();
+ }
if (_logger.IsEnabled(LogLevel.Debug))
{
@@ -306,24 +338,48 @@ private NativeCleanupResult TryFinalizeNativeCleanup()
if (_serverId != 0)
{
+ var serverId = _serverId;
+ _serverId = 0;
+ ShutdownHost(serverId);
+ }
+
+ return NativeCleanupResult.Complete;
+ }
+
+ private void ShutdownHost(uint serverId)
+ {
+ var shutdownTask = Task.Run(() =>
+ {
+ // See s_nativeLifecycleGate: hold it for the true duration of host_shutdown
+ // (even past the point Dispose() below stops waiting), so a concurrent
+ // StartAsync() on another instance can't overlap host_start/connection_open
+ // with this shutdown still draining.
+ s_nativeLifecycleGate.Wait();
try
{
- if (!_hostShutdown(_serverId) && _logger.IsEnabled(LogLevel.Debug))
+ if (!_hostShutdown(serverId) && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
"FfiRuntimeHost: host_shutdown did not recognize server {ServerId}",
- _serverId);
+ serverId);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "FfiRuntimeHost: host_shutdown failed");
}
+ finally
+ {
+ s_nativeLifecycleGate.Release();
+ }
+ });
- _serverId = 0;
+ if (!shutdownTask.Wait(s_hostShutdownTimeout))
+ {
+ _logger.LogWarning(
+ "FfiRuntimeHost: host_shutdown did not complete within {Timeout}; abandoning wait.",
+ s_hostShutdownTimeout);
}
-
- return NativeCleanupResult.Complete;
}
private void ScheduleNativeCleanupRetry()
diff --git a/dotnet/test/E2E/ClientE2ETests.cs b/dotnet/test/E2E/ClientE2ETests.cs
index 282cc9ee67..fadb5557ce 100644
--- a/dotnet/test/E2E/ClientE2ETests.cs
+++ b/dotnet/test/E2E/ClientE2ETests.cs
@@ -74,6 +74,30 @@ public async Task Should_Force_Stop_Without_Cleanup(bool useStdio)
await client.ForceStopAsync();
}
+ // Regression coverage for github/copilot-sdk#2525: ForceStopAsync must be a bounded,
+ // immediate hard stop even for the in-process (FFI) host, where there is no child
+ // process to reap if the native runtime's own shutdown path hangs or is slow (e.g.
+ // while closing its SQLite session store). FfiRuntimeHost.Dispose() bounds its wait
+ // on the native copilot_runtime_host_shutdown call so this cannot hang indefinitely;
+ // this test fails fast (via its own generous timeout) instead of hanging the CI job
+ // if that regresses, and its logged elapsed time doubles as shutdown-performance data.
+ [Fact]
+ public async Task Should_Force_Stop_Over_InProcess_Ffi_Within_Bounded_Time()
+ {
+ using var client = new CopilotClient(new CopilotClientOptions
+ {
+ Connection = RuntimeConnection.ForInProcess(),
+ });
+
+ await Ctx.CreateSessionAsync(client, new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll });
+
+ var forceStopTask = client.ForceStopAsync();
+ var completed = await Task.WhenAny(forceStopTask, Task.Delay(TimeSpan.FromSeconds(30)));
+
+ Assert.Same(forceStopTask, completed);
+ await forceStopTask;
+ }
+
[Theory]
[InlineData(true)] // stdio transport
[InlineData(false)] // TCP transport
diff --git a/go/internal/e2e/inprocess_ffi_e2e_test.go b/go/internal/e2e/inprocess_ffi_e2e_test.go
index 7f7dcc3f20..c57a262b82 100644
--- a/go/internal/e2e/inprocess_ffi_e2e_test.go
+++ b/go/internal/e2e/inprocess_ffi_e2e_test.go
@@ -2,6 +2,7 @@ package e2e
import (
"testing"
+ "time"
copilot "github.com/github/copilot-sdk/go"
"github.com/github/copilot-sdk/go/internal/e2e/testharness"
@@ -59,4 +60,36 @@ func TestInProcessFfiE2E(t *testing.T) {
t.Errorf("Expected no errors on stop, got %v", err)
}
})
+
+ t.Run("should force stop over in-process FFI within a bounded time", func(t *testing.T) {
+ // Regression test for github/copilot-sdk#2525: the in-process FFI
+ // host's Dispose used to call the native host_shutdown export
+ // in-line with no timeout. A slow or stuck native shutdown (observed
+ // on Windows, closing the runtime's SQLite session store) would hang
+ // ForceStop indefinitely, even though ForceStop is documented as the
+ // bounded recovery path for exactly a hung/slow Stop. Asserts that
+ // ForceStop returns within a generous bound instead of hanging.
+ client := copilot.NewClient(&copilot.ClientOptions{
+ Connection: copilot.InProcessConnection{},
+ })
+
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatalf("Failed to start client over in-process FFI: %v", err)
+ }
+ if _, err := client.Ping(t.Context(), "hello before force stop"); err != nil {
+ t.Fatalf("Failed to ping: %v", err)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ client.ForceStop()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(20 * time.Second):
+ t.Fatal("ForceStop did not complete within a bounded time")
+ }
+ })
}
diff --git a/go/internal/ffihost/ffihost.go b/go/internal/ffihost/ffihost.go
index 4add70cb5d..b872f359ab 100644
--- a/go/internal/ffihost/ffihost.go
+++ b/go/internal/ffihost/ffihost.go
@@ -51,6 +51,11 @@ import (
const symbolPrefix = "copilot_runtime_"
+// hostShutdownTimeout bounds how long Dispose waits for the native
+// host_shutdown export; see (*Host).shutdownHost for why this exists. A var,
+// not a const, so tests can shorten it deterministically.
+var hostShutdownTimeout = 10 * time.Second
+
// ffiLibrary binds the copilot_runtime_* C ABI exports of a loaded cdylib.
type ffiLibrary struct {
handle uintptr
@@ -357,16 +362,50 @@ func (h *Host) tryFinalizeCleanupLocked() bool {
serverID := h.serverID
if serverID != 0 {
+ h.serverID = 0
+ h.shutdownHost(serverID)
+ }
+ return true
+}
+
+// shutdownHost calls the native host_shutdown export on a dedicated goroutine
+// and bounds how long callers wait for it.
+//
+// host_shutdown runs the runtime's own teardown (including closing its SQLite
+// session store) synchronously. Calling it in-line with no bound previously
+// meant a slow or stuck native shutdown (observed on Windows in-process — see
+// github/copilot-sdk#2525) could hang whichever goroutine called Dispose,
+// including [Client.ForceStop], which exists specifically as the recovery
+// path for a hung/slow Stop. Running the call on its own goroutine and
+// bounding the wait keeps Dispose (and thus ForceStop) from hanging even if
+// the native call itself never returns; the goroutine still runs the call to
+// completion in the background if the bound elapses first.
+func (h *Host) shutdownHost(serverID uint32) {
+ done := make(chan struct{})
+ go func() {
if !h.lib.hostShutdown(serverID) {
log.Printf("FfiRuntimeHost: host_shutdown did not recognize server %d", serverID)
}
- h.serverID = 0
if h.cliEntrypoint != "" {
// A legacy host may restore its saved SIGCHLD action during shutdown.
rearmForeignSignalHandlers(h.lib.handle)
}
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(hostShutdownTimeout):
+ // The native call (and the signal-handler rearm that follows it) keeps
+ // running on the background goroutine; we just stop waiting here so
+ // the caller is not blocked forever. This should be rare and
+ // indicates a runtime-side shutdown defect worth reporting upstream,
+ // not something for the SDK to retry.
+ log.Printf(
+ "in-process FFI host_shutdown did not complete within %s; abandoning wait (shutdown continues in background)",
+ hostShutdownTimeout,
+ )
}
- return true
}
func (h *Host) scheduleCleanupRetryLocked() {
diff --git a/go/internal/ffihost/ffihost_test.go b/go/internal/ffihost/ffihost_test.go
index 3bb7555a6f..ab571f8a23 100644
--- a/go/internal/ffihost/ffihost_test.go
+++ b/go/internal/ffihost/ffihost_test.go
@@ -196,3 +196,41 @@ func TestDisposeWaitsForStartBeforeShuttingDown(t *testing.T) {
t.Fatalf("Expected shutdown of server 41, got %d", got)
}
}
+
+// Regression test for github/copilot-sdk#2525: Dispose used to call the
+// native host_shutdown export in-line with no bound, so a stuck native
+// shutdown would hang Dispose (and thus Client.ForceStop, which is
+// documented as a bounded recovery path for exactly this kind of hang)
+// forever. Asserts that Dispose gives up waiting once hostShutdownTimeout
+// elapses, even if the native call never returns.
+func TestDisposeAbandonsWaitAfterHostShutdownTimeout(t *testing.T) {
+ originalTimeout := hostShutdownTimeout
+ hostShutdownTimeout = 20 * time.Millisecond
+ defer func() { hostShutdownTimeout = originalTimeout }()
+
+ blockShutdown := make(chan struct{})
+ t.Cleanup(func() { close(blockShutdown) }) // let the stuck goroutine finish so it doesn't leak past the test
+
+ host := &Host{
+ lib: &ffiLibrary{
+ hostShutdown: func(_ uint32) bool {
+ <-blockShutdown
+ return true
+ },
+ },
+ recv: newReceiveBuffer(),
+ serverID: 7,
+ }
+
+ disposeDone := make(chan struct{})
+ go func() {
+ host.Dispose()
+ close(disposeDone)
+ }()
+
+ select {
+ case <-disposeDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("Dispose did not return within a bounded time after a stuck native host_shutdown call")
+ }
+}
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index 035f0f5e60..b4e366192f 100644
--- a/nodejs/src/client.ts
+++ b/nodejs/src/client.ts
@@ -1200,7 +1200,7 @@ export class CopilotClient {
const host = this.ffiHost;
this.ffiHost = null;
try {
- host.dispose();
+ await host.dispose();
} catch (error) {
errors.push(
new Error(
@@ -1315,7 +1315,7 @@ export class CopilotClient {
// Tear down the in-process FFI host (if any).
if (this.ffiHost) {
try {
- this.ffiHost.dispose();
+ await this.ffiHost.dispose();
} catch {
// Ignore errors during force stop
}
diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts
index ee71c44717..778861bad2 100644
--- a/nodejs/src/ffiRuntimeHost.ts
+++ b/nodejs/src/ffiRuntimeHost.ts
@@ -26,6 +26,7 @@ const SYMBOL_PREFIX = "copilot_runtime_";
// connection is open (see start()); the exact interval is irrelevant.
const KEEP_ALIVE_INTERVAL_MS = 1 << 30;
const CLEANUP_RETRY_INTERVAL_MS = 100;
+const HOST_SHUTDOWN_TIMEOUT_MS = 10_000;
type KoffiFunction = ReturnType["func"]>;
type KoffiType = ReturnType;
@@ -243,7 +244,7 @@ export class FfiRuntimeHost {
);
if (!this.connectionId) {
this.unregisterCallback();
- this.lib.hostShutdown(this.serverId);
+ this.shutdownHost(this.serverId);
this.serverId = 0;
throw new Error("copilot_runtime_connection_open failed.");
}
@@ -373,17 +374,7 @@ export class FfiRuntimeHost {
}
if (this.serverId) {
- try {
- if (!this.lib.hostShutdown(this.serverId)) {
- console.error(
- `In-process FFI host shutdown did not recognize server ${this.serverId}.`
- );
- }
- } catch (error) {
- console.error(
- `Failed to shut down in-process FFI host: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
- );
- }
+ this.shutdownHost(this.serverId);
this.serverId = 0;
}
if (callbackUnregistered) {
@@ -405,4 +396,37 @@ export class FfiRuntimeHost {
this.tryFinalizeCleanup();
}
}
+
+ private shutdownHost(serverId: number): void {
+ const complete = (error: Error | null, result: boolean) => {
+ completed = true;
+ clearTimeout(timeout);
+ if (error) {
+ console.error(
+ `Failed to shut down in-process FFI host: ${error.stack ?? error.message}`
+ );
+ } else if (!result) {
+ console.error(`In-process FFI host shutdown did not recognize server ${serverId}.`);
+ }
+ };
+ let completed = false;
+ const timeout = setTimeout(() => {
+ if (!completed) {
+ console.error(
+ `In-process FFI host_shutdown did not complete within ${HOST_SHUTDOWN_TIMEOUT_MS}ms; abandoning wait.`
+ );
+ }
+ }, HOST_SHUTDOWN_TIMEOUT_MS).unref();
+
+ if (typeof this.lib.hostShutdown.async === "function") {
+ this.lib.hostShutdown.async(serverId, complete);
+ return;
+ }
+
+ try {
+ complete(null, Boolean(this.lib.hostShutdown(serverId)));
+ } catch (error) {
+ complete(error as Error, false);
+ }
+ }
}
diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts
index a529ea8e4c..06014ffe20 100644
--- a/nodejs/test/e2e/client.e2e.test.ts
+++ b/nodejs/test/e2e/client.e2e.test.ts
@@ -125,6 +125,33 @@ describe("Client", () => {
await client.forceStop();
});
+ // Regression test for github/copilot-sdk#2525: the in-process FFI host's dispose()
+ // used to call the native host_shutdown export synchronously with no timeout, which
+ // on Node blocks the entire event loop until it returns. A slow/stuck native shutdown
+ // (observed on Windows with the runtime's SQLite session store) would hang stop()
+ // indefinitely. Asserting a bounded completion time here catches any regression back
+ // to an unbounded/synchronous wait.
+ it.runIf(isInProcessTransport)(
+ "should stop within a bounded time over the in-process transport",
+ async () => {
+ const client = new CopilotClient({});
+ onTestFinishedStop(client);
+
+ await client.createSession({ onPermissionRequest: approveAll });
+
+ const timedOut = Symbol("timeout");
+ const result = await Promise.race([
+ client.stop().then(() => "stopped" as const),
+ new Promise((resolvePromise) =>
+ setTimeout(() => resolvePromise(timedOut), 20_000).unref()
+ ),
+ ]);
+
+ expect(result).toBe("stopped");
+ },
+ 30_000
+ );
+
it("should get status with version and protocol info", async () => {
const client = new CopilotClient();
onTestFinishedStop(client);
diff --git a/python/copilot/_ffi_runtime_host.py b/python/copilot/_ffi_runtime_host.py
index 8674ed6ebc..9994f81837 100644
--- a/python/copilot/_ffi_runtime_host.py
+++ b/python/copilot/_ffi_runtime_host.py
@@ -48,6 +48,7 @@
_SYMBOL_PREFIX = "copilot_runtime_"
_CLEANUP_RETRY_INTERVAL_SECONDS = 0.1
+_HOST_SHUTDOWN_TIMEOUT_SECONDS = 10.0
# The C ABI outbound callback: void(void *user_data, uint8 *bytes, size_t len).
_OutboundCallback = ctypes.CFUNCTYPE(
@@ -448,7 +449,7 @@ def start_blocking(self) -> None:
)
if not self._connection_id:
self._outbound_callback = None
- self._lib.host_shutdown(self._server_id)
+ self._shutdown_host(self._server_id)
self._server_id = 0
raise RuntimeError("copilot_runtime_connection_open failed.")
finally:
@@ -526,15 +527,9 @@ def _try_finalize_cleanup(self) -> None:
self._quarantined_hosts.discard(self)
if self._server_id:
- try:
- if not self._lib.host_shutdown(self._server_id):
- logger.debug(
- "In-process FFI host shutdown did not recognize server %s",
- self._server_id,
- )
- except Exception: # noqa: BLE001
- logger.debug("Error shutting down in-process FFI host", exc_info=True)
+ server_id = self._server_id
self._server_id = 0
+ self._shutdown_host(server_id)
def _schedule_cleanup_retry(self) -> None:
if self._cleanup_timer is not None:
@@ -548,3 +543,28 @@ def _run_cleanup_retry(self) -> None:
with self._dispose_lock:
self._cleanup_timer = None
self._try_finalize_cleanup()
+
+ def _shutdown_host(self, server_id: int) -> None:
+ """Call native host_shutdown on a daemon thread with a bounded wait."""
+ done = threading.Event()
+
+ def run() -> None:
+ try:
+ if not self._lib.host_shutdown(server_id):
+ logger.debug(
+ "In-process FFI host shutdown did not recognize server %s",
+ server_id,
+ )
+ except Exception: # noqa: BLE001
+ logger.debug("Error shutting down in-process FFI host", exc_info=True)
+ finally:
+ done.set()
+
+ threading.Thread(target=run, name="copilot-ffi-host-shutdown", daemon=True).start()
+
+ if not done.wait(timeout=_HOST_SHUTDOWN_TIMEOUT_SECONDS):
+ logger.warning(
+ "In-process FFI host_shutdown did not complete within %.0fs; "
+ "abandoning wait (shutdown continues on a background thread).",
+ _HOST_SHUTDOWN_TIMEOUT_SECONDS,
+ )
diff --git a/python/e2e/test_inprocess_ffi_e2e.py b/python/e2e/test_inprocess_ffi_e2e.py
index ea82037b7a..5e6cf8238d 100644
--- a/python/e2e/test_inprocess_ffi_e2e.py
+++ b/python/e2e/test_inprocess_ffi_e2e.py
@@ -10,6 +10,8 @@
from __future__ import annotations
+import asyncio
+
import pytest
from copilot import CopilotClient, RuntimeConnection
@@ -32,3 +34,19 @@ async def test_should_start_and_connect_over_in_process_ffi(self, ctx: E2ETestCo
assert pong.timestamp is not None
finally:
await client.stop()
+
+ async def test_should_force_stop_over_in_process_ffi_within_bounded_time(
+ self, ctx: E2ETestContext
+ ):
+ # Regression test for github/copilot-sdk#2525: the in-process FFI host's
+ # dispose() used to call the native host_shutdown export synchronously
+ # with no timeout. A slow or stuck native shutdown (observed on Windows,
+ # closing the runtime's SQLite session store) would hang force_stop
+ # indefinitely, even though force_stop exists specifically as the
+ # recovery path for a hung/slow stop(). Asserting a bounded completion
+ # time here catches any regression back to an unbounded wait.
+ client = CopilotClient(connection=RuntimeConnection.for_inprocess())
+ await client.start()
+ await client.ping("hello before force_stop")
+
+ await asyncio.wait_for(client.force_stop(), timeout=20.0)
diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs
index 02fce3f030..8a1c825b4e 100644
--- a/rust/src/ffi.rs
+++ b/rust/src/ffi.rs
@@ -41,6 +41,8 @@ type ConnectionOpenFn = unsafe extern "C" fn(
type ConnectionWriteFn = unsafe extern "C" fn(u32, *const u8, usize) -> bool;
type ConnectionCloseFn = unsafe extern "C" fn(u32) -> bool;
+const HOST_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
+
/// State handed to the native side as `user_data` so the outbound callback can
/// route inbound frames back to the reader.
struct CallbackState {
@@ -113,12 +115,8 @@ impl FfiShared {
std::thread::sleep(std::time::Duration::from_millis(100));
}
release_callback_state(state);
- if server != 0 && !unsafe { host_shutdown(server) } {
- warn!(
- library = %library_path.display(),
- server_id = server,
- "FFI runtime host shutdown did not recognize server"
- );
+ if server != 0 {
+ shutdown_host(host_shutdown, server, &library_path);
}
debug!(library = %library_path.display(), "FFI runtime connection closed");
})
@@ -138,12 +136,8 @@ impl FfiShared {
.callback_state
.swap(std::ptr::null_mut(), Ordering::SeqCst) as usize;
release_callback_state(state);
- if server != 0 && !unsafe { (self.host_shutdown)(server) } {
- warn!(
- library = %self.library_path.display(),
- server_id = server,
- "FFI runtime host shutdown did not recognize server"
- );
+ if server != 0 {
+ shutdown_host(self.host_shutdown, server, &self.library_path);
}
debug!(library = %self.library_path.display(), "FFI runtime connection closed");
}
@@ -170,6 +164,31 @@ fn release_callback_state(state: usize) {
drop(unsafe { Box::from_raw(state) });
}
+fn shutdown_host(host_shutdown: HostShutdownFn, server: u32, library_path: &Path) {
+ let library_path = library_path.to_path_buf();
+ let shutdown_library_path = library_path.clone();
+ let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
+ std::thread::spawn(move || {
+ if !unsafe { host_shutdown(server) } {
+ warn!(
+ library = %shutdown_library_path.display(),
+ server_id = server,
+ "FFI runtime host shutdown did not recognize server"
+ );
+ }
+ let _ = done_tx.send(());
+ });
+
+ if done_rx.recv_timeout(HOST_SHUTDOWN_TIMEOUT).is_err() {
+ warn!(
+ library = %library_path.display(),
+ timeout_ms = HOST_SHUTDOWN_TIMEOUT.as_millis(),
+ "FFI host_shutdown did not complete within timeout; abandoning wait \
+ (shutdown continues on a background thread)",
+ );
+ }
+}
+
impl Drop for FfiShared {
fn drop(&mut self) {
self.close();
diff --git a/rust/tests/e2e/client_lifecycle.rs b/rust/tests/e2e/client_lifecycle.rs
index 92bfa6ff6d..c3893569a6 100644
--- a/rust/tests/e2e/client_lifecycle.rs
+++ b/rust/tests/e2e/client_lifecycle.rs
@@ -3,6 +3,8 @@ use github_copilot_sdk::CliProgram;
use github_copilot_sdk::SessionLifecycleEventType;
use serde_json::json;
+#[cfg(windows)]
+use super::support::skip_inprocess;
use super::support::{wait_for_lifecycle_event, with_e2e_context};
#[tokio::test]
@@ -144,6 +146,10 @@ async fn dispose_disconnects_client_and_disposes_rpc_surface_drop() {
#[cfg(windows)]
#[tokio::test]
async fn abrupt_host_termination_still_kills_cli_via_job_object() {
+ if skip_inprocess("job-object containment is specific to the stdio CLI child process") {
+ return;
+ }
+
with_e2e_context(
"client_lifecycle",
"abrupt_host_termination_still_kills_cli_via_job_object",
@@ -226,14 +232,17 @@ async fn abrupt_host_termination_still_kills_cli_via_job_object() {
#[cfg(windows)]
async fn wait_for_pid_file_windows(path: &std::path::Path) -> u32 {
super::support::wait_for_condition("host-crash fixture CLI pid file", || async {
- path.exists()
+ std::fs::read_to_string(path)
+ .ok()
+ .and_then(|contents| contents.trim().parse::().ok())
+ .is_some()
})
.await;
- std::fs::read_to_string(path)
- .expect("read host-crash fixture CLI pid")
+ let contents = std::fs::read_to_string(path).expect("read host-crash fixture CLI pid");
+ contents
.trim()
.parse()
- .expect("parse host-crash fixture CLI pid")
+ .unwrap_or_else(|err| panic!("parse host-crash fixture CLI pid from {contents:?}: {err}"))
}
#[cfg(windows)]
diff --git a/rust/tests/e2e/inprocess.rs b/rust/tests/e2e/inprocess.rs
index ead05a0b58..6531c0d6ba 100644
--- a/rust/tests/e2e/inprocess.rs
+++ b/rust/tests/e2e/inprocess.rs
@@ -29,3 +29,44 @@ async fn should_start_ping_and_stop_inprocess_client() {
})
.await;
}
+
+/// Regression test for github/copilot-sdk#2525: `force_stop` is documented as
+/// a synchronous, infallible recovery path, but it used to call the native
+/// `host_shutdown` export in-line with no bound. A slow or stuck native
+/// shutdown (observed on Windows in-process, closing the runtime's SQLite
+/// session store) would hang `force_stop` itself, defeating its purpose as
+/// the fallback for exactly that kind of hang. Asserting that `force_stop`
+/// returns quickly, on a dedicated thread bounded by a generous timeout,
+/// catches any regression back to an unbounded, in-line wait.
+#[tokio::test]
+async fn should_force_stop_inprocess_client_within_bounded_time() {
+ with_e2e_context(
+ "client",
+ "should_force_stop_inprocess_client_within_bounded_time",
+ |ctx| {
+ Box::pin(async move {
+ let client = ctx.start_inprocess_client().await;
+ client
+ .ping(Some("hello before force_stop"))
+ .await
+ .expect("ping over in-process FFI transport");
+
+ let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
+ std::thread::spawn(move || {
+ client.force_stop();
+ let _ = done_tx.send(());
+ });
+
+ tokio::time::timeout(
+ std::time::Duration::from_secs(30),
+ tokio::task::spawn_blocking(move || done_rx.recv()),
+ )
+ .await
+ .expect("force_stop should complete within a bounded time")
+ .expect("blocking task should not panic")
+ .expect("force_stop thread should signal completion");
+ })
+ },
+ )
+ .await;
+}