返回 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 range 50 {
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 ctx := sandbox.WithPermissionPreset(context.Background(), "danger-full-access")
72 out, err := (bash{
73 shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: bashPath},
74 }).Execute(ctx, argsJSON(t, map[string]any{"command": command}))
75 if err != nil {
76 t.Fatalf("bash Execute failed: %v (out=%q)", err, out)
77 }
78
79 data, err := os.ReadFile(pidFile)
80 if err != nil {
81 t.Fatalf("read pid file: %v", err)
82 }
83 pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
84 if err != nil {
85 t.Fatalf("parse backgrounded pid %q: %v", data, err)
86 }
87 defer func() { _ = syscall.Kill(pid, syscall.SIGKILL) }()
88
89 time.Sleep(200 * time.Millisecond)
90 if err := syscall.Kill(pid, 0); err != nil {
91 t.Fatalf("nohup/disown child %d did not survive bash completion: %v", pid, err)
92 }
93 }
94
95 func TestExplicitBackgroundKeepaliveDetection(t *testing.T) {
96 tests := []struct {
97 name string
98 command string
99 want bool
100 }{
101 {
102 name: "nohup background",
103 command: "nohup python train.py >train.log 2>&1 &",
104 want: true,
105 },
106 {
107 name: "disown background",
108 command: "sleep 60 >/dev/null 2>&1 & disown",
109 want: true,
110 },
111 {
112 name: "setsid background",
113 command: "setsid sleep 60 >/dev/null 2>&1 &",
114 want: true,
115 },
116 {
117 name: "command wrapper before nohup",
118 command: "command nohup sleep 60 >/dev/null 2>&1 &",
119 want: true,
120 },
121 {
122 name: "env assignment wrapper before nohup",
123 command: "env CUDA_VISIBLE_DEVICES=0 nohup python train.py >/dev/null 2>&1 &",
124 want: true,
125 },
126 {
127 name: "quoted command name still static",
128 command: `"nohup" sleep 60 >/dev/null 2>&1 &`,
129 want: true,
130 },
131 {
132 name: "plain background still reaped",
133 command: "sleep 60 >/dev/null 2>&1 &",
134 want: false,
135 },
136 {
137 name: "nohup without background still reaped",
138 command: "nohup sleep 1",
139 want: false,
140 },
141 {
142 name: "quoted nohup argument ignored",
143 command: "echo 'nohup sleep 60 &' &",
144 want: false,
145 },
146 {
147 name: "process substitution quoted keepalive text ignored",
148 command: "cat <(printf '%s\\n' 'nohup sleep 60 &')",
149 want: false,
150 },
151 {
152 name: "process substitution real keepalive command",
153 command: "cat <(nohup sleep 60 >/dev/null 2>&1 &)",
154 want: true,
155 },
156 {
157 name: "dynamic command name is not preserved",
158 command: `cmd=nohup; "$cmd" sleep 60 >/dev/null 2>&1 &`,
159 want: false,
160 },
161 {
162 name: "parse failure is conservative",
163 command: "nohup sleep 60 & '",
164 want: false,
165 },
166 {
167 name: "redirection ampersand ignored",
168 command: "nohup sleep 1 2>&1",
169 want: false,
170 },
171 {
172 name: "redirect target before command ignored",
173 command: "> nohup echo done &",
174 want: false,
175 },
176 {
177 name: "assignment before nohup",
178 command: "CUDA_VISIBLE_DEVICES=0 nohup python train.py >/dev/null 2>&1 &",
179 want: true,
180 },
181 {
182 name: "heredoc body ampersand and parens are not keepalive",
183 command: strings.Join([]string{
184 "cat > /tmp/test_redact.go <<'EOF'",
185 "func main() {",
186 "\tjson.Unmarshal(data, &v)",
187 "}",
188 "EOF",
189 }, "\n"),
190 want: false,
191 },
192 {
193 name: "heredoc body keepalive text is not keepalive",
194 command: strings.Join([]string{
195 "cat > /tmp/repro.txt <<'EOF'",
196 "nohup sleep 60 >/dev/null 2>&1 &",
197 "EOF",
198 }, "\n"),
199 want: false,
200 },
201 }
202
203 for _, tt := range tests {
204 t.Run(tt.name, func(t *testing.T) {
205 if got := hasExplicitBackgroundKeepalive(tt.command); got != tt.want {
206 t.Fatalf("hasExplicitBackgroundKeepalive(%q) = %v, want %v", tt.command, got, tt.want)
207 }
208 })
209 }
210 }
211
212 func TestShouldReapAfterRunHonorsExplicitPreserveOnlyOnCompletion(t *testing.T) {
213 sh := sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}
214 if shouldReapAfterRun(context.Background(), sh, "sleep 60 >/dev/null 2>&1 &", true) {
215 t.Fatal("preserve_background_processes should skip reap after normal completion")
216 }
217
218 ctx, cancel := context.WithCancel(context.Background())
219 cancel()
220 if !shouldReapAfterRun(ctx, sh, "sleep 60 >/dev/null 2>&1 &", true) {
221 t.Fatal("cancelled commands should still reap the process group")
222 }
223 }
224
225 func TestShellPATHProbeDetachesControllingTerminal(t *testing.T) {
226 cmd := exec.CommandContext(context.Background(), "sh", "-c", "true")
227 proc.PrepareShellPATHProbe(cmd)
228
229 if cmd.SysProcAttr == nil {
230 t.Fatal("SysProcAttr is nil")
231 }
232 if !cmd.SysProcAttr.Setsid {
233 t.Fatal("login shell PATH probe should run in a new session so an interactive shell cannot take the TUI foreground")
234 }
235 }
236
237 func shellQuote(s string) string {
238 return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
239 }
240
240 lines GO