Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions .github/workflows/dotnet-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,24 @@ 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 Windows is excluded from the `capi`
# in-process cell for the same reason as Linux/macOS below; see the
# windows-latest/inprocess include cells further down for its
# in-process coverage via the alternate backends.
- 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
Expand Down Expand Up @@ -182,6 +186,28 @@ jobs:
backend: openai-completions
shard: full
test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly"
# Windows in-process coverage (github/copilot-sdk#2525). Previously excluded
# entirely because of a napi-oop cleanup race and a suspected in-process SQLite
# file-locking issue on shutdown; napi-oop is no longer used by the runtime, and
# FfiRuntimeHost.Dispose() now bounds its wait on native shutdown so a slow or
# stuck runtime teardown cannot hang the job. Uses the same non-capi backends as
# the Linux cell above to avoid the unrelated CLI 1.0.81-2 in-process CAPI
# regression tracked separately.
- os: windows-latest
transport: inprocess
backend: anthropic-messages
shard: full
test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly"
- os: windows-latest
transport: inprocess
backend: openai-responses
shard: full
test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly"
- os: windows-latest
transport: inprocess
backend: openai-completions
shard: full
test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly"
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.
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/rust-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,12 @@ jobs:
strategy:
fail-fast: false
matrix:
# TODO: Re-enable Windows after fixing the napi-oop peer shutdown crash.
os: [ubuntu-latest, macos-latest]
# 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 failure mode no
# longer applies; see github/copilot-sdk#2525 and #1934. Re-enabled
# so Windows gets the same in-process E2E coverage as Linux/macOS.
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
defaults:
Expand Down
74 changes: 65 additions & 9 deletions dotnet/src/FfiRuntimeHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*--------------------------------------------------------------------------------------------*/

using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
Expand Down Expand Up @@ -38,6 +39,24 @@
/// <summary>Logical name the native interop layer binds the cdylib to.</summary>
private const string LibraryName = "copilot_runtime";

/// <summary>
/// Upper bound on how long <see cref="Dispose"/> waits for the native
/// <c>copilot_runtime_host_shutdown</c> call to return.
/// </summary>
/// <remarks>
/// This call runs the loaded runtime's own teardown (including closing its SQLite
/// session store) synchronously in this process, with no cancellation hook exposed
/// across the FFI boundary. A caller may already have asked the runtime to shut down
/// gracefully over JSON-RPC (<c>Runtime.ShutdownAsync</c>) before reaching here, so
/// this call is expected to be fast; it exists mainly to release the loaded
/// library's resources. But because in-process hosting shares this process (there is
/// no child process to kill if it does not return), a stuck or slow native shutdown
/// would otherwise hang <see cref="Dispose"/> forever, defeating <c>ForceStopAsync</c>'s
/// contract of an immediate hard stop. Bounding the wait keeps teardown deterministic
/// even if the runtime's shutdown path never returns; see github/copilot-sdk#2525.
/// </remarks>
private static readonly TimeSpan s_hostShutdownTimeout = TimeSpan.FromSeconds(10);

private readonly ILogger _logger;
private readonly string? _cliEntrypoint;
private readonly string _libraryPath;
Expand Down Expand Up @@ -225,21 +244,58 @@
_logger.LogDebug(ex, "FfiRuntimeHost: connection_close failed");
}

try
_receiveStream.Complete();

var serverId = _serverId;
_serverId = 0;
if (serverId == 0)
{
if (_serverId != 0)
DisposeNativeCallback();
return;
}

var shutdownTimestamp = Stopwatch.GetTimestamp();

// Run the blocking native call on a pooled thread so this Dispose() call can
// enforce a bound on it instead of hanging indefinitely if the runtime's own
// shutdown never returns. The callback GCHandle is freed only once the native
// call actually returns (inside the task, not here), so a slow-but-eventually-
// completing shutdown cannot race a native callback against a freed handle even
// when this method stops waiting early.
var shutdownTask = Task.Run(() =>
{
try
{
NativeHostShutdown(_serverId);
_serverId = 0;
NativeHostShutdown(serverId);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "FfiRuntimeHost: host_shutdown failed");
}
Comment on lines +307 to +310
finally
{
DisposeNativeCallback();
}
});

if (shutdownTask.Wait(s_hostShutdownTimeout))
{
LoggingHelpers.LogTiming(_logger, LogLevel.Debug, null,
"FfiRuntimeHost: host_shutdown complete. Elapsed={Elapsed}",
shutdownTimestamp);
}
catch (Exception ex)
else
{
_logger.LogDebug(ex, "FfiRuntimeHost: host_shutdown failed");
// The native call (and the callback cleanup that follows it) keeps running
// on the abandoned background thread; we just stop waiting on it here so the
// caller (e.g. ForceStopAsync) 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.
LoggingHelpers.LogTiming(_logger, LogLevel.Warning, null,
"FfiRuntimeHost: host_shutdown did not complete within Elapsed={Elapsed}, Timeout={Timeout}; abandoning wait.",
shutdownTimestamp,
s_hostShutdownTimeout);
}

_receiveStream.Complete();
DisposeNativeCallback();
}

/// <summary>Length as the native pointer-sized unsigned integer the ABI expects.</summary>
Expand Down
24 changes: 24 additions & 0 deletions dotnet/test/E2E/ClientE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions go/internal/e2e/inprocess_ffi_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
})
}
49 changes: 44 additions & 5 deletions go/internal/ffihost/ffihost.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,24 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"

"github.com/ebitengine/purego"
)

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 shrink it temporarily.
var hostShutdownTimeout = 10 * time.Second

// ffiLibrary binds the copilot_runtime_* C ABI exports of a loaded cdylib.
type ffiLibrary struct {
handle uintptr
Expand Down Expand Up @@ -223,10 +230,7 @@ func (h *Host) Start() error {
if h.connectionID == 0 {
outboundTargets.Delete(callbackToken)
h.callbackToken = 0
h.lib.hostShutdown(h.serverID)
if h.cliEntrypoint != "" {
rearmForeignSignalHandlers(h.lib.handle)
}
h.shutdownHost(h.serverID)
h.serverID = 0
return fmt.Errorf("copilot_runtime_connection_open failed")
}
Expand Down Expand Up @@ -358,14 +362,49 @@ func (h *Host) Dispose() {
if connID != 0 {
h.lib.connectionClose(connID)
}
h.recv.Close()

if serverID != 0 {
h.shutdownHost(serverID)
}
}

// 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() {
h.lib.hostShutdown(serverID)
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,
)
}
h.recv.Close()
}

// hostWriter adapts Host into the io.WriteCloser jsonrpc2 writes request frames to.
Expand Down
38 changes: 38 additions & 0 deletions go/internal/ffihost/ffihost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,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")
}
}
4 changes: 2 additions & 2 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ export class CopilotClient {
const host = this.ffiHost;
this.ffiHost = null;
try {
host.dispose();
await host.dispose();
} catch (error) {
errors.push(
new Error(
Expand Down Expand Up @@ -1292,7 +1292,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
}
Expand Down
Loading
Loading