1//go:build !windows
2
3package subagents
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "strconv"
13 "strings"
14 "syscall"
15 "testing"
16 "time"
17)
18
19func TestWorkerCancellationKillsProcessGroup(t *testing.T) {
20 shell, err := exec.LookPath("sh")
21 if err != nil {
22 t.Skip("sh is not available")
23 }
24
25 pidPath := t.TempDir() + "/child.pid"
26 ctx, cancel := context.WithCancel(context.Background())
27 defer cancel()
28
29 cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath))
30 configureWorkerProcess(cmd)
31 if cmd.WaitDelay != workerProcessWaitDelay {
32 t.Fatalf("WaitDelay = %s, want %s", cmd.WaitDelay, workerProcessWaitDelay)
33 }
34 if err := cmd.Start(); err != nil {
35 t.Fatal(err)
36 }
37
38 childPID := waitForWorkerChildPID(t, pidPath)
39 cancel()
40
41 waitDone := make(chan error, 1)
42 go func() { waitDone <- cmd.Wait() }()
43 select {
44 case <-time.After(2 * time.Second):
45 t.Fatal("worker did not exit after context cancellation")
46 case err := <-waitDone:
47 if err == nil {
48 t.Fatal("worker exited successfully after context cancellation")
49 }
50 }
51
52 deadline := time.Now().Add(2 * time.Second)
53 for time.Now().Before(deadline) && processExists(childPID) {
54 time.Sleep(10 * time.Millisecond)
55 }
56 if processExists(childPID) {
57 t.Fatalf("worker descendant pid %d survived process-group cancellation", childPID)
58 }
59}
60
61func TestWorkerCancellationClosesOutputPipes(t *testing.T) {
62 cmd := exec.Command("sh")
63 stdout, err := cmd.StdoutPipe()
64 if err != nil {
65 t.Fatal(err)
66 }
67 stderr, err := cmd.StderrPipe()
68 if err != nil {
69 t.Fatal(err)
70 }
71 defer stdout.Close()
72 defer stderr.Close()
73
74 configureWorkerProcess(cmd, stdout, stderr)
75 if err := cmd.Cancel(); err != nil {
76 t.Fatal(err)
77 }
78 for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {
79 data := make([]byte, 1)
80 if _, err := pipe.Read(data); !errors.Is(err, os.ErrClosed) {
81 t.Errorf("%s read error = %v, want os.ErrClosed", name, err)
82 }
83 }
84}
85
86func waitForWorkerChildPID(t *testing.T, path string) int {
87 t.Helper()
88 deadline := time.Now().Add(2 * time.Second)
89 for time.Now().Before(deadline) {
90 data, err := os.ReadFile(path)
91 if err == nil {
92 pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
93 if parseErr == nil && pid > 0 {
94 return pid
95 }
96 }
97 time.Sleep(10 * time.Millisecond)
98 }
99 t.Fatalf("timed out waiting for child pid in %s", path)
100 return 0
101}
102
103func processExists(pid int) bool {
104 err := syscall.Kill(pid, 0)
105 return err == nil || errors.Is(err, syscall.EPERM)
106}