返回 DeepSeek-Reasonix
bash_cancel_windows_test.go
根目录 / internal / tool / builtin / bash_cancel_windows_test.go
1 //go:build windows
2
3 package builtin
4
5 import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "reasonix/internal/sandbox"
18 )
19
20 func TestBashCancelKillsWindowsChildProcessTree(t *testing.T) {
21 powershell, err := exec.LookPath("powershell")
22 if err != nil {
23 t.Skip("powershell not found")
24 }
25 tmp := t.TempDir()
26 pidFile := filepath.Join(tmp, "child.pid")
27 quotedPIDFile := strings.ReplaceAll(pidFile, "'", "''")
28 command := fmt.Sprintf(
29 "$p = Start-Process -FilePath powershell -ArgumentList '-NoProfile','-NonInteractive','-Command','Start-Sleep -Seconds 120' -PassThru; "+
30 "Set-Content -LiteralPath '%s' -Value $p.Id; "+
31 "Start-Sleep -Seconds 120",
32 quotedPIDFile,
33 )
34 args, _ := json.Marshal(map[string]any{"command": command})
35 ctx, cancel := context.WithCancel(context.Background())
36 defer cancel()
37
38 done := make(chan error, 1)
39 go func() {
40 _, runErr := (bash{
41 shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: powershell},
42 }).Execute(ctx, args)
43 done <- runErr
44 }()
45
46 childPID := waitForWindowsPIDFile(t, pidFile)
47 cancel()
48 select {
49 case err = <-done:
50 case <-time.After(40 * time.Second):
51 killWindowsPID(childPID)
52 t.Fatal("cancel did not interrupt bash within 40s")
53 }
54 if err == nil {
55 t.Fatal("expected cancel to return an error")
56 }
57 for i := 0; i < 50; i++ {
58 if !windowsProcessAlive(childPID) {
59 return
60 }
61 time.Sleep(100 * time.Millisecond)
62 }
63 killWindowsPID(childPID)
64 t.Fatalf("child process %d survived bash cancel", childPID)
65 }
66
67 func TestBashWindowsReapsChildAfterForegroundShellExit(t *testing.T) {
68 powershell, err := exec.LookPath("powershell")
69 if err != nil {
70 t.Skip("powershell not found")
71 }
72 tmp := t.TempDir()
73 pidFile := filepath.Join(tmp, "child.pid")
74 quotedPIDFile := strings.ReplaceAll(pidFile, "'", "''")
75 command := fmt.Sprintf(
76 "$p = Start-Process -FilePath powershell -ArgumentList '-NoProfile','-NonInteractive','-Command','Start-Sleep -Seconds 120' -PassThru; "+
77 "Set-Content -LiteralPath '%s' -Value $p.Id",
78 quotedPIDFile,
79 )
80 args, _ := json.Marshal(map[string]any{"command": command})
81
82 out, err := (bash{
83 shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: powershell},
84 }).Execute(context.Background(), args)
85 childPID := waitForWindowsPIDFile(t, pidFile)
86 if err != nil {
87 killWindowsPID(childPID)
88 t.Fatalf("foreground command failed: %v (out=%q)", err, out)
89 }
90 for i := 0; i < 50; i++ {
91 if !windowsProcessAlive(childPID) {
92 return
93 }
94 time.Sleep(100 * time.Millisecond)
95 }
96 killWindowsPID(childPID)
97 t.Fatalf("child process %d survived foreground bash cleanup", childPID)
98 }
99
100 func TestBashCancelKillsGitBashHereDocPython(t *testing.T) {
101 sh := sandbox.ResolveShell("bash", "", nil)
102 if sh.Kind != sandbox.ShellBash || sh.Path == "" {
103 t.Skip("Git Bash not found")
104 }
105 python := gitBashPython(t, sh.Path)
106 tmp := t.TempDir()
107 pidFile := filepath.Join(tmp, "python.pid")
108 pythonPIDFile := filepath.ToSlash(pidFile)
109 command := fmt.Sprintf("%s - <<'PYEOF'\nimport os, time\nwith open(%q, 'w') as f:\n f.write(str(os.getpid()))\n f.flush()\ntime.sleep(120)\nPYEOF\n", shellQuote(python), pythonPIDFile)
110 args, _ := json.Marshal(map[string]any{"command": command})
111 ctx, cancel := context.WithCancel(context.Background())
112 defer cancel()
113
114 done := make(chan error, 1)
115 go func() {
116 _, runErr := (bash{shell: sh}).Execute(ctx, args)
117 done <- runErr
118 }()
119
120 childPID := waitForWindowsPIDFile(t, pidFile)
121 time.Sleep(300 * time.Millisecond) // let the tracker observe the Git Bash child tree.
122 cancel()
123 select {
124 case err := <-done:
125 if err == nil {
126 t.Fatal("expected cancel to return an error")
127 }
128 case <-time.After(20 * time.Second):
129 killWindowsPID(childPID)
130 t.Fatal("cancel did not interrupt Git Bash here-doc python within 20s")
131 }
132 for i := 0; i < 50; i++ {
133 if !windowsProcessAlive(childPID) {
134 return
135 }
136 time.Sleep(100 * time.Millisecond)
137 }
138 killWindowsPID(childPID)
139 t.Fatalf("Git Bash here-doc python process %d survived bash cancel", childPID)
140 }
141
142 func waitForWindowsPIDFile(t *testing.T, path string) int {
143 t.Helper()
144 deadline := time.Now().Add(10 * time.Second)
145 for time.Now().Before(deadline) {
146 data, err := os.ReadFile(path)
147 if err == nil {
148 pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
149 if parseErr == nil && pid > 0 {
150 return pid
151 }
152 }
153 time.Sleep(100 * time.Millisecond)
154 }
155 t.Fatalf("timed out waiting for child pid file %s", path)
156 return 0
157 }
158
159 func gitBashPython(t *testing.T, bashPath string) string {
160 t.Helper()
161 for _, name := range []string{"python3", "python"} {
162 path, err := exec.LookPath(name)
163 if err != nil {
164 continue
165 }
166 python := gitBashPath(t, bashPath, path)
167 out, err := exec.Command(bashPath, "-lc", fmt.Sprintf("%s - <<'PYEOF'\nprint('ok')\nPYEOF\n", shellQuote(python))).CombinedOutput()
168 if err == nil {
169 return python
170 }
171 t.Logf("python candidate %s is not usable from Git Bash: %v: %s", python, err, strings.TrimSpace(string(out)))
172 }
173 t.Skip("python not found or not usable from Git Bash")
174 return ""
175 }
176
177 func gitBashPath(t *testing.T, bashPath, path string) string {
178 t.Helper()
179 out, err := exec.Command(bashPath, "-lc", fmt.Sprintf("cygpath -u %s", shellQuote(path))).Output()
180 if err == nil {
181 converted := strings.TrimSpace(string(out))
182 if converted != "" {
183 return strings.Split(converted, "\n")[0]
184 }
185 }
186 return filepath.ToSlash(path)
187 }
188
189 func shellQuote(s string) string {
190 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
191 }
192
193 func windowsProcessAlive(pid int) bool {
194 cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", fmt.Sprintf("if (Get-Process -Id %d -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }", pid))
195 return cmd.Run() == nil
196 }
197
198 func killWindowsPID(pid int) {
199 _ = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)).Run()
200 }
201
201 lines GO