Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 9 additions & 2 deletions .github/workflows/required-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,18 @@ jobs:
java: ${{ steps.select.outputs.java }}
rust: ${{ steps.select.outputs.rust }}
steps:
- name: Check out merge group
if: github.event_name == 'merge_group'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2

- name: Detect changed paths
id: filter
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
uses: dorny/paths-filter@6852f92c20ea7fd3b0c25de3b5112db3a98da050 # v3
with:
base: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }}
predicate-quantifier: every
filters: |
orchestrator:
Expand Down Expand Up @@ -90,7 +97,7 @@ jobs:
JAVA_CHANGED: ${{ steps.filter.outputs.java }}
RUST_CHANGED: ${{ steps.filter.outputs.rust }}
run: |
if [[ "$EVENT_NAME" != "pull_request" || "$ORCHESTRATOR_CHANGED" == "true" ]]; then
if [[ "$EVENT_NAME" == "workflow_dispatch" || "$ORCHESTRATOR_CHANGED" == "true" ]]; then
for workflow in nodejs python go dotnet java rust; do
echo "$workflow=true" >> "$GITHUB_OUTPUT"
done
Expand Down
7 changes: 5 additions & 2 deletions dotnet/test/E2E/SessionE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,10 @@ public async Task Handler_Exception_Does_Not_Halt_Event_Delivery()
[Fact]
public async Task DisposeAsync_From_Handler_Does_Not_Deadlock()
{
var session = await CreateSessionAsync();
var client = Ctx.CreateClient();
var session = await Ctx.CreateSessionAsync(
client,
new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll });
var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

session.On<SessionEvent>(evt =>
Expand All @@ -774,7 +777,7 @@ public async Task DisposeAsync_From_Handler_Does_Not_Deadlock()
// If this times out, we deadlocked.
await disposed.Task.WaitAsync(TimeSpan.FromSeconds(10));

await Client.ForceStopAsync();
await client.ForceStopAsync();
}

[Fact]
Expand Down
49 changes: 45 additions & 4 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public sealed class E2ETestContext : IAsyncDisposable
private readonly object _clientsLock = new();
private readonly List<CopilotClient> _persistentClients = [];
private readonly List<CopilotClient> _transientClients = [];
private readonly List<CopilotSession> _testSessions = [];

private E2ETestContext(string homeDir, string workDir, string proxyUrl, ReplayProxy proxy, string repoRoot)
{
Expand Down Expand Up @@ -389,23 +390,33 @@ public CopilotClient CreateClient(
return client;
}

public Task<CopilotSession> CreateSessionAsync(
public async Task<CopilotSession> CreateSessionAsync(
CopilotClient client,
SessionConfig? config = null)
{
config ??= new SessionConfig();
E2ETestBackendConfiguration.Current.ApplyProvider(config, ProxyUrl);
return client.CreateSessionAsync(config);
var session = await client.CreateSessionAsync(config);
lock (_clientsLock)
{
_testSessions.Add(session);
}
return session;
}

public Task<CopilotSession> ResumeSessionAsync(
public async Task<CopilotSession> ResumeSessionAsync(
CopilotClient client,
string sessionId,
ResumeSessionConfig? config = null)
{
config ??= new ResumeSessionConfig();
E2ETestBackendConfiguration.Current.ApplyProvider(config, ProxyUrl);
return client.ResumeSessionAsync(sessionId, config);
var session = await client.ResumeSessionAsync(sessionId, config);
lock (_clientsLock)
{
_testSessions.Add(session);
}
return session;
}

internal void PrepareForTest()
Expand Down Expand Up @@ -445,14 +456,29 @@ public async Task CleanupAfterTestAsync()
// Per-test cleanup only stops clients created for a specific test.
// The shared persistent client and temp directories are cleaned when the fixture is disposed.
var errors = new List<Exception>();
CopilotSession[] testSessions;
CopilotClient[] transientClients;

lock (_clientsLock)
{
testSessions = [.. _testSessions];
_testSessions.Clear();
transientClients = [.. _transientClients];
_transientClients.Clear();
}

foreach (var session in testSessions)
{
try
{
await session.DisposeAsync();
}
catch (Exception ex) when (IsTransientCleanupException(ex))
{
errors.Add(ex);
}
}

foreach (var client in transientClients)
{
try
Expand All @@ -478,15 +504,30 @@ public async Task CleanupAfterTestAsync()
public async ValueTask DisposeAsync()
{
var errors = new List<Exception>();
CopilotSession[] testSessions;
CopilotClient[] clients;

lock (_clientsLock)
{
testSessions = [.. _testSessions];
_testSessions.Clear();
clients = [.. _persistentClients.Concat(_transientClients)];
_persistentClients.Clear();
_transientClients.Clear();
}

foreach (var session in testSessions)
{
try
{
await session.DisposeAsync();
}
catch (Exception ex) when (IsTransientCleanupException(ex))
{
errors.Add(ex);
}
}

foreach (var client in clients)
{
try
Expand Down
15 changes: 12 additions & 3 deletions go/internal/e2e/testharness/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ func NewTestContext(t *testing.T) *TestContext {
}

t.Cleanup(func() {
ctx.Close(t.Failed())
if err := ctx.Close(t.Failed()); err != nil {
t.Errorf("Failed to close E2E test context: %v", err)
}
})

return ctx
Expand Down Expand Up @@ -266,11 +268,17 @@ func (c *TestContext) ConfigureWithoutSnapshot(t *testing.T) {
}

// Close cleans up the test context resources.
func (c *TestContext) Close(testFailed bool) {
func (c *TestContext) Close(testFailed bool) error {
if c.inProcess {
if err := waitForInProcessCleanup(); err != nil {
return err
}
}
c.restoreInProcessEnvironment()
var proxyErr error
if c.proxy != nil {
if err := c.proxy.StopWithOptions(testFailed); err != nil {
fmt.Fprintf(os.Stderr, "Failed to stop E2E proxy: %v\n", err)
proxyErr = fmt.Errorf("failed to stop E2E proxy: %w", err)
}
}
if c.HomeDir != "" {
Expand All @@ -279,6 +287,7 @@ func (c *TestContext) Close(testFailed bool) {
if c.WorkDir != "" {
os.RemoveAll(c.WorkDir)
}
return proxyErr
}

// applyInProcessEnvironment mirrors the isolated test environment onto the real
Expand Down
7 changes: 7 additions & 0 deletions go/internal/e2e/testharness/inprocess_cleanup_disabled.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !copilot_inprocess || (!darwin && !linux && !windows)

package testharness

func waitForInProcessCleanup() error {
return nil
}
18 changes: 18 additions & 0 deletions go/internal/e2e/testharness/inprocess_cleanup_enabled.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build copilot_inprocess && (darwin || linux || windows)

package testharness

import (
"fmt"
"time"

"github.com/github/copilot-sdk/go/internal/ffihost"
)

func waitForInProcessCleanup() error {
const timeout = 10 * time.Second
if !ffihost.WaitForCleanup(timeout) {
return fmt.Errorf("timed out after %s waiting for deferred in-process cleanup", timeout)
}
return nil
}
51 changes: 51 additions & 0 deletions go/internal/ffihost/ffihost.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,55 @@ var (
nextOutboundToken atomic.Uint64
)

var pendingCleanup = struct {
sync.Mutex
count int
idle chan struct{}
}{
idle: closedChannel(),
}

func closedChannel() chan struct{} {
ch := make(chan struct{})
close(ch)
return ch
}

func beginPendingCleanup() {
pendingCleanup.Lock()
defer pendingCleanup.Unlock()
if pendingCleanup.count == 0 {
pendingCleanup.idle = make(chan struct{})
}
pendingCleanup.count++
}

func finishPendingCleanup() {
pendingCleanup.Lock()
defer pendingCleanup.Unlock()
pendingCleanup.count--
if pendingCleanup.count == 0 {
close(pendingCleanup.idle)
}
}

// WaitForCleanup waits for all deferred connection cleanup to finish.
// In-process test harnesses use this before changing process-global state.
func WaitForCleanup(timeout time.Duration) bool {
pendingCleanup.Lock()
idle := pendingCleanup.idle
pendingCleanup.Unlock()

timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-idle:
return true
case <-timer.C:
return false
}
}

func sharedOutboundCallback() uintptr {
outboundCallbackOnce.Do(func() {
outboundCallbackHandle = purego.NewCallback(routeOutbound)
Expand Down Expand Up @@ -374,7 +423,9 @@ func (h *Host) scheduleCleanupRetryLocked() {
return
}
h.cleanupScheduled = true
beginPendingCleanup()
go func() {
defer finishPendingCleanup()
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
for range timer.C {
Expand Down
12 changes: 5 additions & 7 deletions go/internal/ffihost/ffihost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,13 @@ func TestDisposeRetainsOutboundTargetUntilConnectionCloseSucceeds(t *testing.T)
if got := shutdownCalls.Load(); got != 0 {
t.Fatalf("Expected host shutdown to be deferred, got %d calls", got)
}
if WaitForCleanup(20 * time.Millisecond) {
t.Fatal("Expected cleanup wait to remain blocked before connection close succeeds")
}

allowClose.Store(true)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
_, registered := outboundTargets.Load(token)
if !registered && shutdownCalls.Load() == 1 {
break
}
time.Sleep(10 * time.Millisecond)
if !WaitForCleanup(5 * time.Second) {
t.Fatal("Timed out waiting for deferred cleanup")
}

if _, ok := outboundTargets.Load(token); ok {
Expand Down
43 changes: 43 additions & 0 deletions nodejs/test/rust-codegen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { ApiSchema } from "../../scripts/codegen/utils.ts";
import { describe, expect, it } from "vitest";

import { generateApiTypesCode } from "../../scripts/codegen/rust.ts";

describe("Rust API type codegen", () => {
it("distinguishes a protocol-defined unknown value from the forward-compatible fallback", () => {
const code = generateApiTypesCode({
definitions: {
CatalogTrustEligibility: {
type: "string",
enum: ["default", "expanded", "hidden", "unknown"],
},
},
} as ApiSchema);

expect(code).toContain(`#[serde(rename = "unknown")]
UnknownValue,
/// Unknown variant for forward compatibility.
#[default]
#[serde(other)]
Unknown,`);
});

it("publicly re-exports API types moved into the shared session-events schema", () => {
const code = generateApiTypesCode({
definitions: {
PermissionDecision: {
type: "object",
required: ["source"],
properties: {
source: {
$ref: "session-events.schema.json#/definitions/PermissionDecisionSource",
},
},
},
},
} as ApiSchema);

expect(code).toContain("pub use super::session_events::{PermissionDecisionSource};");
expect(code).toContain("use crate::types::{RequestId, SessionId};");
});
});
2 changes: 1 addition & 1 deletion rust/src/generated/api_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::collections::HashMap;

use serde::{Deserialize, Serialize};

use super::session_events::{
pub use super::session_events::{
AbortReason, AgentModelPolicy, AutoTier, ContextTier, McpOauthHttpResponse,
McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus,
ModelChangeSource, OmittedBinaryOmittedReason, PermissionMode, PermissionPromptRequest,
Expand Down
Loading
Loading