iop/apps/node/internal/workspace/cleanup_test.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

318 lines
11 KiB
Go

package workspace
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"golang.org/x/sys/unix"
iop "iop/proto/gen/iop"
)
func TestWorkspaceCleanupArtifactsDuplicateRaceAndIsolation(t *testing.T) {
root := t.TempDir()
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
for _, requestID := range []string{"request-a", "request-b"} {
if _, err := runtime.Open(testRequestAuthority(requestID)); err != nil {
t.Fatal(err)
}
}
if err := runtime.WriteInternalArtifact("request-a", "plan.md", []byte("plan")); err != nil {
t.Fatal(err)
}
if err := runtime.WriteInternalArtifact("request-a", "nested/review.md", []byte("review")); err != nil {
t.Fatal(err)
}
if err := runtime.WriteInternalArtifact("request-b", "plan.md", []byte("foreign request")); err != nil {
t.Fatal(err)
}
userResult := filepath.Join(root, "result.txt")
if err := os.WriteFile(userResult, []byte("preserve"), 0o600); err != nil {
t.Fatal(err)
}
const callers = 24
results := make(chan CleanupResult, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
results <- runtime.Cleanup(context.Background(), "request-a")
}()
}
group.Wait()
close(results)
var first *CleanupResult
for result := range results {
if first == nil {
copy := result
first = &copy
}
if result != *first {
t.Fatalf("cleanup callers observed different results: first=%+v got=%+v", *first, result)
}
}
if first == nil || first.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || first.CleanedArtifacts != 4 {
t.Fatalf("cleanup result = %+v", first)
}
if duplicate := runtime.Cleanup(context.Background(), "request-a"); duplicate != *first {
t.Fatalf("duplicate cleanup = %+v, want %+v", duplicate, *first)
}
if _, err := runtime.Open(testRequestAuthority("request-a")); err != ErrRequestConflict {
t.Fatalf("completed request identity reopened: %v", err)
}
if _, err := os.Lstat(requestArtifactRoot(root, "request-a")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("request-a tree remains: %v", err)
}
if data, err := os.ReadFile(filepath.Join(requestArtifactRoot(root, "request-b"), "plan.md")); err != nil || string(data) != "foreign request" {
t.Fatalf("request-b artifact = %q, %v", data, err)
}
if data, err := os.ReadFile(userResult); err != nil || string(data) != "preserve" {
t.Fatalf("user result = %q, %v", data, err)
}
}
func TestWorkspaceCleanupCancelsActiveProcessGroup(t *testing.T) {
root := t.TempDir()
runtime := newCommandRuntime(t, root, 64)
openCommandRequest(t, runtime, "request-process", 64)
userResult := filepath.Join(root, "user-result.txt")
if err := os.WriteFile(userResult, []byte("preserve"), 0o600); err != nil {
t.Fatal(err)
}
if err := runtime.WriteInternalArtifact("request-process", "plan.md", []byte("plan")); err != nil {
t.Fatal(err)
}
pidFile := filepath.Join(root, "cleanup-child.pid")
input := commandInput("request-process", "tool-process", "group")
input.Environment["IOP_CHILD_PID_FILE"] = pidFile
resultCh := make(chan Result, 1)
go func() { resultCh <- runtime.ExecuteCommand(context.Background(), input) }()
waitForFile(t, pidFile)
payload, err := os.ReadFile(pidFile)
if err != nil {
t.Fatal(err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(payload)))
if err != nil {
t.Fatal(err)
}
cleanup := runtime.Cleanup(context.Background(), "request-process")
if cleanup.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || cleanup.CleanedProcesses != 1 || cleanup.CleanedArtifacts != 2 {
t.Fatalf("cleanup = %+v", cleanup)
}
if result := <-resultCh; result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_CANCELLED {
t.Fatalf("command result = %+v", result)
}
deadline := time.Now().Add(2 * time.Second)
for processExists(pid) && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if processExists(pid) {
t.Fatalf("descendant process %d survived cleanup", pid)
}
if _, err := os.Lstat(requestArtifactRoot(root, "request-process")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("request artifacts remain: %v", err)
}
if data, err := os.ReadFile(userResult); err != nil || string(data) != "preserve" {
t.Fatalf("cleanup changed user result: %q, %v", data, err)
}
}
func TestWorkspaceCleanupTimeoutIsBoundedAndCached(t *testing.T) {
runtime, root := openedRuntime(t)
if err := runtime.WriteInternalArtifact("request-1", "plan.md", []byte("preserve on timeout")); err != nil {
t.Fatal(err)
}
req, err := runtime.Request("request-1")
if err != nil {
t.Fatal(err)
}
execution := newCommandExecution()
key := commandKey{requestID: "request-1", toolCallID: "tool-stuck"}
if !runtime.registerCommand(req, key, execution) {
t.Fatal("failed to install deterministic stuck command")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
started := time.Now()
result := runtime.Cleanup(ctx, "request-1")
if result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_TIMEOUT || result.Code != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_TIMEOUT || result.CleanedProcesses != 1 {
t.Fatalf("cleanup timeout = %+v", result)
}
if time.Since(started) > time.Second {
t.Fatalf("cleanup exceeded bound: %s", time.Since(started))
}
if duplicate := runtime.Cleanup(context.Background(), "request-1"); duplicate != result {
t.Fatalf("cached timeout = %+v, want %+v", duplicate, result)
}
if _, err := os.Stat(filepath.Join(requestArtifactRoot(root, "request-1"), "plan.md")); err != nil {
t.Fatalf("timed-out cleanup removed artifact: %v", err)
}
execution.finish()
runtime.commandsMu.Lock()
delete(runtime.activeCommands, key)
runtime.commandsMu.Unlock()
}
func TestWorkspaceCleanupRefusesUnownedAndUnsafeEntries(t *testing.T) {
tests := map[string]func(*testing.T, *Runtime, string, string){
"unowned entry": func(t *testing.T, _ *Runtime, root, requestID string) {
if err := os.WriteFile(filepath.Join(requestArtifactRoot(root, requestID), "injected.txt"), []byte("unowned"), 0o600); err != nil {
t.Fatal(err)
}
},
"symlink": func(t *testing.T, _ *Runtime, root, requestID string) {
if err := os.Symlink(filepath.Join(root, "outside"), filepath.Join(requestArtifactRoot(root, requestID), "link")); err != nil {
t.Fatal(err)
}
},
"identity replacement": func(t *testing.T, runtime *Runtime, root, requestID string) {
if err := runtime.WriteInternalArtifact(requestID, "plan.md", []byte("owned")); err != nil {
t.Fatal(err)
}
target := filepath.Join(requestArtifactRoot(root, requestID), "plan.md")
if err := os.Remove(target); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte("replacement"), 0o600); err != nil {
t.Fatal(err)
}
},
"special file": func(t *testing.T, _ *Runtime, root, requestID string) {
if err := unix.Mkfifo(filepath.Join(requestArtifactRoot(root, requestID), "pipe"), 0o600); err != nil {
t.Fatal(err)
}
},
"mount device boundary": func(t *testing.T, runtime *Runtime, _ string, requestID string) {
req, err := runtime.Request(requestID)
if err != nil {
t.Fatal(err)
}
req.mu.Lock()
rootArtifact := req.artifacts[req.internalPrefix]
rootArtifact.device++
req.artifacts[req.internalPrefix] = rootArtifact
req.mu.Unlock()
},
}
for name, inject := range tests {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
requestID := "request-refuse"
if _, err := runtime.Open(testRequestAuthority(requestID)); err != nil {
t.Fatal(err)
}
userResult := filepath.Join(root, "user-result.txt")
if err := os.WriteFile(userResult, []byte("preserve"), 0o600); err != nil {
t.Fatal(err)
}
inject(t, runtime, root, requestID)
result := runtime.Cleanup(context.Background(), requestID)
if result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR || result.Code != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL {
t.Fatalf("cleanup = %+v", result)
}
if _, err := os.Lstat(requestArtifactRoot(root, requestID)); err != nil {
t.Fatalf("suspect request tree was removed: %v", err)
}
if data, err := os.ReadFile(userResult); err != nil || string(data) != "preserve" {
t.Fatalf("failed cleanup changed user result: %q, %v", data, err)
}
})
}
}
func TestWorkspaceCleanupRejectsInvalidOrPreexistingIdentity(t *testing.T) {
root := t.TempDir()
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
if result := runtime.Cleanup(context.Background(), "../foreign"); result.Code != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST {
t.Fatalf("invalid cleanup = %+v", result)
}
preexisting := requestArtifactRoot(root, "request-existing")
if err := os.MkdirAll(preexisting, 0o700); err != nil {
t.Fatal(err)
}
if _, err := runtime.Open(testRequestAuthority("request-existing")); err != ErrInvalidRequest {
t.Fatalf("preexisting request namespace was admitted: %v", err)
}
if _, err := os.Stat(preexisting); err != nil {
t.Fatalf("preexisting namespace was changed: %v", err)
}
}
func TestWorkspaceCleanupRuntimeCloseUsesSamePrimitive(t *testing.T) {
root := t.TempDir()
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", nil)
if err != nil {
t.Fatal(err)
}
if _, err := runtime.Open(testRequestAuthority("request-close")); err != nil {
t.Fatal(err)
}
userResult := filepath.Join(root, "user-result.txt")
if err := os.WriteFile(userResult, []byte("preserve"), 0o600); err != nil {
t.Fatal(err)
}
if err := runtime.WriteInternalArtifact("request-close", "review.md", []byte("review")); err != nil {
t.Fatal(err)
}
if err := runtime.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(requestArtifactRoot(root, "request-close")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime close left request artifacts: %v", err)
}
if data, err := os.ReadFile(userResult); err != nil || string(data) != "preserve" {
t.Fatalf("runtime close changed user result: %q, %v", data, err)
}
}
func TestWorkspaceCleanupCompletedCacheIsBounded(t *testing.T) {
root := t.TempDir()
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
for index := 0; index < completedCleanupLimit+32; index++ {
requestID := "request-cache-" + strconv.Itoa(index)
if _, err := runtime.Open(testRequestAuthority(requestID)); err != nil {
t.Fatal(err)
}
if result := runtime.Cleanup(context.Background(), requestID); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("cleanup %d = %+v", index, result)
}
}
runtime.cleanupMu.Lock()
calls, order := len(runtime.cleanupCalls), len(runtime.cleanupOrder)
runtime.cleanupMu.Unlock()
if calls != completedCleanupLimit || order != completedCleanupLimit {
t.Fatalf("completed cleanup cache = calls %d order %d, want %d", calls, order, completedCleanupLimit)
}
}
func requestArtifactRoot(root, requestID string) string {
return filepath.Join(root, ".iop", "job", requestID)
}