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