返回 DeepSeek-Reasonix
bash_reap_test.go
根目录 / internal / tool / builtin / bash_reap_test.go
1 //go:build !windows
2
3 package builtin
4
5 import (
6 "context"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "syscall"
13 "testing"
14 "time"
15
16 "reasonix/internal/proc"
17 "reasonix/internal/sandbox"
18 )
19
20 // TestReapTreeKillsGroupStragglers covers #3702: a foreground command that
21 // backgrounds a child (here a long sleep, standing in for `bazel run`'s server)
22 // leaves it in the process group after Wait reaps the shell leader. KillTree must
23 // kill it so such processes don't accumulate into an OOM. The child redirects its
24 // fds and the pid is passed via a file so the inherited stdout can't block Wait.
25 func TestReapTreeKillsGroupStragglers(t *testing.T) {
26 pidFile := filepath.Join(t.TempDir(), "pid")
27 cmd := exec.CommandContext(context.Background(), "sh", "-c",
28 "sleep 60 >/dev/null 2>&1 & echo $! > "+pidFile)
29 proc.SetCancelKillsTree(cmd) // new session — the shell leads its own group
30 if err := cmd.Run(); err != nil {
31 t.Fatalf("run: %v", err)
32 }
33
34 data, err := os.ReadFile(pidFile)
35 if err != nil {
36 t.Fatalf("read pid file: %v", err)
37 }
38 pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
39 if err != nil {
40 t.Fatalf("parse backgrounded pid %q: %v", data, err)
41 }
42 if err := syscall.Kill(pid, 0); err != nil {
43 t.Skipf("backgrounded child %d not alive after shell exit (%v)", pid, err)
44 }
45
46 proc.KillTree(cmd)
47
48 dead := false
49 for i := 0; i < 50; i++ {
50 if syscall.Kill(pid, 0) != nil {
51 dead = true
52 break
53 }
54 time.Sleep(20 * time.Millisecond)
55 }
56 if !dead {
57 _ = syscall.Kill(pid, syscall.SIGKILL) // don't leak the sleep in CI
58 t.Fatalf("backgrounded child %d survived reapTree", pid)
59 }
60 }
61
62 func TestBashPreservesExplicitNoHupDisown(t *testing.T) {
63 bashPath, err := exec.LookPath("bash")
64 if err != nil {
65 t.Skip("bash not found")
66 }
67
68 dir := t.TempDir()
69 pidFile := filepath.Join(dir, "pid")
70 command := "nohup sleep 60 >/dev/null 2>&1 & echo $! > " + shellQuote(pidFile) + "; disown"
71 out, err := (bash{
72 shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: bashPath},
73 }).Execute(context.Background(), argsJSON(t, map[string]any{"command": command}))
74 if err != nil {
75 t.Fatalf("bash Execute failed: %v (out=%q)", err, out)
76 }
77
78 data, err := os.ReadFile(pidFile)
79 if err != nil {
80 t.Fatalf("read pid file: %v", err)
81 }
82 pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
83 if err != nil {
84 t.Fatalf("parse backgrounded pid %q: %v", data, err)
85 }
86 defer func() { _ = syscall.Kill(pid, syscall.SIGKILL) }()
87
88 time.Sleep(200 * time.Millisecond)
89 if err := syscall.Kill(pid, 0); err != nil {
90 t.Fatalf("nohup/disown child %d did not survive bash completion: %v", pid, err)
91 }
92 }
93
94 func TestExplicitBackgroundKeepaliveDetection(t *testing.T) {
95 tests := []struct {
96 name string
97 command string
98 want bool
99 }{
100 {
101 name: "nohup background",
102 command: "nohup python train.py >train.log 2>&1 &",
103 want: true,
104 },
105 {
106 name: "disown background",
107 command: "sleep 60 >/dev/null 2>&1 & disown",
108 want: true,
109 },
110 {
111 name: "setsid background",
112 command: "setsid sleep 60 >/dev/null 2>&1 &",
113 want: true,
114 },
115 {
116 name: "command wrapper before nohup",
117 command: "command nohup sleep 60 >/dev/null 2>&1 &",
118 want: true,
119 },
120 {
121 name: "env assignment wrapper before nohup",
122 command: "env CUDA_VISIBLE_DEVICES=0 nohup python train.py >/dev/null 2>&1 &",
123 want: true,
124 },
125 {
126 name: "quoted command name still static",
127 command: `"nohup" sleep 60 >/dev/null 2>&1 &`,
128 want: true,
129 },
130 {
131 name: "plain background still reaped",
132 command: "sleep 60 >/dev/null 2>&1 &",
133 want: false,
134 },
135 {
136 name: "nohup without background still reaped",
137 command: "nohup sleep 1",
138 want: false,
139 },
140 {
141 name: "quoted nohup argument ignored",
142 command: "echo 'nohup sleep 60 &' &",
143 want: false,
144 },
145 {
146 name: "process substitution quoted keepalive text ignored",
147 command: "cat <(printf '%s\\n' 'nohup sleep 60 &')",
148 want: false,
149 },
150 {
151 name: "process substitution real keepalive command",
152 command: "cat <(nohup sleep 60 >/dev/null 2>&1 &)",
153 want: true,
154 },
155 {
156 name: "dynamic command name is not preserved",
157 command: `cmd=nohup; "$cmd" sleep 60 >/dev/null 2>&1 &`,
158 want: false,
159 },
160 {
161 name: "parse failure is conservative",
162 command: "nohup sleep 60 & '",
163 want: false,
164 },
165 {
166 name: "redirection ampersand ignored",
167 command: "nohup sleep 1 2>&1",
168 want: false,
169 },
170 {
171 name: "redirect target before command ignored",
172 command: "> nohup echo done &",
173 want: false,
174 },
175 {
176 name: "assignment before nohup",
177 command: "CUDA_VISIBLE_DEVICES=0 nohup python train.py >/dev/null 2>&1 &",
178 want: true,
179 },
180 {
181 name: "heredoc body ampersand and parens are not keepalive",
182 command: strings.Join([]string{
183 "cat > /tmp/test_redact.go <<'EOF'",
184 "func main() {",
185 "\tjson.Unmarshal(data, &v)",
186 "}",
187 "EOF",
188 }, "\n"),
189 want: false,
190 },
191 {
192 name: "heredoc body keepalive text is not keepalive",
193 command: strings.Join([]string{
194 "cat > /tmp/repro.txt <<'EOF'",
195 "nohup sleep 60 >/dev/null 2>&1 &",
196 "EOF",
197 }, "\n"),
198 want: false,
199 },
200 }
201
202 for _, tt := range tests {
203 t.Run(tt.name, func(t *testing.T) {
204 if got := hasExplicitBackgroundKeepalive(tt.command); got != tt.want {
205 t.Fatalf("hasExplicitBackgroundKeepalive(%q) = %v, want %v", tt.command, got, tt.want)
206 }
207 })
208 }
209 }
210
211 func TestShouldReapAfterRunHonorsExplicitPreserveOnlyOnCompletion(t *testing.T) {
212 sh := sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}
213 if shouldReapAfterRun(context.Background(), sh, "sleep 60 >/dev/null 2>&1 &", true) {
214 t.Fatal("preserve_background_processes should skip reap after normal completion")
215 }
216
217 ctx, cancel := context.WithCancel(context.Background())
218 cancel()
219 if !shouldReapAfterRun(ctx, sh, "sleep 60 >/dev/null 2>&1 &", true) {
220 t.Fatal("cancelled commands should still reap the process group")
221 }
222 }
223
224 func TestShellPATHProbeDetachesControllingTerminal(t *testing.T) {
225 cmd := exec.CommandContext(context.Background(), "sh", "-c", "true")
226 proc.PrepareShellPATHProbe(cmd)
227
228 if cmd.SysProcAttr == nil {
229 t.Fatal("SysProcAttr is nil")
230 }
231 if !cmd.SysProcAttr.Setsid {
232 t.Fatal("login shell PATH probe should run in a new session so an interactive shell cannot take the TUI foreground")
233 }
234 }
235
236 func shellQuote(s string) string {
237 return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
238 }
239
239 lines GO