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
2 changes: 1 addition & 1 deletion dotnet/test/E2E/RpcAdditionalEdgeCasesE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public async Task Shell_Exec_With_Zero_Timeout_Does_Not_Kill_Long_Running_Comman
var session = await CreateSessionAsync();
var markerPath = Path.Join(Ctx.WorkDir, $"shell-zero-timeout-{Guid.NewGuid():N}.txt");
var command = OperatingSystem.IsWindows()
? $"powershell -NoLogo -NoProfile -Command \"Start-Sleep -Milliseconds 500; Set-Content -LiteralPath '{markerPath}' -Value 'alive'; Start-Sleep -Seconds 60\""
? $"ping 127.0.0.1 -n 2 >nul & echo alive>\"{markerPath}\" & ping 127.0.0.1 -n 61 >nul"
: $"sh -c \"sleep 0.5; printf alive > '{markerPath}'; sleep 60\"";

var execResult = await session.Rpc.Shell.ExecAsync(command, cwd: Path.GetTempPath(), timeout: TimeSpan.Zero);
Expand Down
13 changes: 10 additions & 3 deletions go/internal/e2e/testharness/context.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package testharness

import (
"fmt"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -167,7 +168,9 @@ func NewTestContext(t *testing.T) *TestContext {
// use a snapshot replace this empty configuration before model traffic begins.
dummySnapshotPath := filepath.Join(workDir, "__no_snapshot__.yaml")
if err := proxy.Configure(dummySnapshotPath, workDir); err != nil {
proxy.StopWithOptions(true)
if stopErr := proxy.StopWithOptions(true); stopErr != nil {
t.Logf("Failed to stop proxy after initialization error: %v", stopErr)
}
os.RemoveAll(homeDir)
os.RemoveAll(workDir)
t.Fatalf("Failed to initialize proxy: %v", err)
Expand All @@ -181,7 +184,9 @@ func NewTestContext(t *testing.T) *TestContext {
},
"analytics_tracking_id": "e2e-test-tracking-id",
}); err != nil {
proxy.StopWithOptions(true)
if stopErr := proxy.StopWithOptions(true); stopErr != nil {
t.Logf("Failed to stop proxy after configuration error: %v", stopErr)
}
os.RemoveAll(homeDir)
os.RemoveAll(workDir)
t.Fatalf("Failed to configure default Copilot user: %v", err)
Expand Down Expand Up @@ -264,7 +269,9 @@ func (c *TestContext) ConfigureWithoutSnapshot(t *testing.T) {
func (c *TestContext) Close(testFailed bool) {
c.restoreInProcessEnvironment()
if c.proxy != nil {
c.proxy.StopWithOptions(testFailed)
if err := c.proxy.StopWithOptions(testFailed); err != nil {
fmt.Fprintf(os.Stderr, "Failed to stop E2E proxy: %v\n", err)
}
}
if c.HomeDir != "" {
os.RemoveAll(c.HomeDir)
Expand Down
62 changes: 57 additions & 5 deletions go/internal/e2e/testharness/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
)

const proxyShutdownTimeout = 5 * time.Second

// CapiProxy manages a child process that acts as a replaying proxy to AI endpoints.
// It spawns the shared test harness server from test/harness/server.ts.
type CapiProxy struct {
Expand Down Expand Up @@ -118,6 +124,11 @@ func (p *CapiProxy) StopWithOptions(skipWritingCache bool) error {
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
cmd := p.cmd
defer func() {
p.cmd = nil
p.proxyURL = ""
}()

// Send stop request to the server
if p.proxyURL != "" {
Expand All @@ -126,20 +137,61 @@ func (p *CapiProxy) StopWithOptions(skipWritingCache bool) error {
stopURL += "?skipWritingCache=true"
}
// Best effort - ignore errors
resp, err := http.Post(stopURL, "application/json", nil)
client := http.Client{Timeout: proxyShutdownTimeout}
resp, err := client.Post(stopURL, "application/json", nil)
if err == nil {
resp.Body.Close()
}
}

// Wait for process to exit
p.cmd.Wait()
p.cmd = nil
p.proxyURL = ""
exited := make(chan struct{}, 1)
go func() {
_ = cmd.Wait()
exited <- struct{}{}
}()
if !waitForProcessExit(exited, proxyShutdownTimeout) {
if err := killProcessTree(cmd); err != nil {
return fmt.Errorf("failed to kill proxy process: %w", err)
}
if !waitForProcessExit(exited, proxyShutdownTimeout) {
return fmt.Errorf("proxy process did not exit after being killed")
}
}
return nil
}

func killProcessTree(cmd *exec.Cmd) error {
if runtime.GOOS == "windows" {
taskkill := exec.Command(
"taskkill",
"/PID",
strconv.Itoa(cmd.Process.Pid),
"/T",
"/F",
)
if err := taskkill.Run(); err == nil {
return nil
}
}

if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return nil
}

func waitForProcessExit(exited <-chan struct{}, timeout time.Duration) bool {
timer := time.NewTimer(timeout)
defer timer.Stop()

select {
case <-exited:
return true
case <-timer.C:
return false
}
}

// Configure sends configuration to the proxy.
func (p *CapiProxy) Configure(filePath, workDir string) error {
p.mu.Lock()
Expand Down
18 changes: 7 additions & 11 deletions nodejs/test/e2e/extension_env_access.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { approveAll, RuntimeConnection } from "../../src/index.js";
import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js";
import { createSdkTestContext, getLegacyCliPathForTests } from "./harness/sdkTestContext.js";
import { retry } from "./harness/sdkTestHelper.js";
import { retry, stopChildProcess } from "./harness/sdkTestHelper.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURE = join(__dirname, "fixtures", "env-access-extension.mjs");
Expand Down Expand Up @@ -120,16 +120,12 @@ async function runExtensionAgainstStubHost(options: {
};
} finally {
connection.dispose();
child.kill();
// Windows keeps the directory locked until the child is gone.
await new Promise<void>((resolveExit) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolveExit();
return;
}
child.once("exit", () => resolveExit());
});
await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
try {
await stopChildProcess(child);
} finally {
// Windows keeps the directory locked until the child is gone.
await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
}
}
}

Expand Down
49 changes: 49 additions & 0 deletions nodejs/test/e2e/harness/sdkTestHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,57 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import type { ChildProcess } from "node:child_process";
import { AssistantMessageEvent, CopilotSession, SessionEvent } from "../../../src";

const CHILD_SHUTDOWN_TIMEOUT_MS = 1_000;

export async function stopChildProcess(child: ChildProcess): Promise<void> {
if (hasChildExited(child)) {
return;
}

child.kill("SIGTERM");
if (await waitForChildExit(child, CHILD_SHUTDOWN_TIMEOUT_MS)) {
return;
}

child.kill("SIGKILL");
if (!(await waitForChildExit(child, CHILD_SHUTDOWN_TIMEOUT_MS))) {
throw new Error("Child process did not exit after SIGKILL");
}
}

function hasChildExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}

function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (hasChildExited(child)) {
return Promise.resolve(true);
}

return new Promise<boolean>((resolvePromise) => {
let settled = false;
const finish = (exited: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
child.off("exit", onExit);
resolvePromise(exited);
};
const onExit = () => finish(true);
const timeout = setTimeout(() => finish(false), timeoutMs);

child.once("exit", onExit);
if (hasChildExited(child)) {
onExit();
}
});
}

export async function getFinalAssistantMessage(
session: CopilotSession,
{ alreadyIdle = false }: { alreadyIdle?: boolean } = {}
Expand Down
17 changes: 3 additions & 14 deletions nodejs/test/e2e/mcp_oauth.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { describe, expect, it, onTestFinished } from "vitest";
import type { CopilotSession, MCPServerConfig, McpAuthRequest } from "../../src/index.js";
import { approveAll } from "../../src/index.js";
import { createSdkTestContext } from "./harness/sdkTestContext.js";
import { waitForCondition } from "./harness/sdkTestHelper.js";
import { stopChildProcess, waitForCondition } from "./harness/sdkTestHelper.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -322,7 +322,7 @@ async function startOAuthMcpServer(): Promise<{
env: { ...process.env, EXPECTED_TOKEN },
stdio: ["ignore", "pipe", "pipe"],
});
onTestFinished(() => stopChild(child));
onTestFinished(() => stopChildProcess(child));

const stderr: string[] = [];
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
Expand Down Expand Up @@ -375,17 +375,6 @@ async function disconnectSession(session: CopilotSession): Promise<void> {
}
}

function stopChild(child: ChildProcessWithoutNullStreams): Promise<void> {
if (child.exitCode !== null || child.killed) {
return Promise.resolve();
}
const exitPromise = new Promise<void>((resolvePromise) => {
child.once("exit", () => resolvePromise());
});
child.kill("SIGTERM");
return exitPromise;
}

function createAsyncQueue<T>(): { push(value: T): void; next(): Promise<T> } {
const values: T[] = [];
const waiters: Array<(value: T) => void> = [];
Expand Down
Loading
Loading