Files
2026-07-13 13:00:08 +08:00

239 lines
6.3 KiB
Go

//go:build !windows
package builtin
import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"reasonix/internal/proc"
"reasonix/internal/sandbox"
)
// TestReapTreeKillsGroupStragglers covers #3702: a foreground command that
// backgrounds a child (here a long sleep, standing in for `bazel run`'s server)
// leaves it in the process group after Wait reaps the shell leader. KillTree must
// kill it so such processes don't accumulate into an OOM. The child redirects its
// fds and the pid is passed via a file so the inherited stdout can't block Wait.
func TestReapTreeKillsGroupStragglers(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "pid")
cmd := exec.CommandContext(context.Background(), "sh", "-c",
"sleep 60 >/dev/null 2>&1 & echo $! > "+pidFile)
proc.SetCancelKillsTree(cmd) // new session — the shell leads its own group
if err := cmd.Run(); err != nil {
t.Fatalf("run: %v", err)
}
data, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("read pid file: %v", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
t.Fatalf("parse backgrounded pid %q: %v", data, err)
}
if err := syscall.Kill(pid, 0); err != nil {
t.Skipf("backgrounded child %d not alive after shell exit (%v)", pid, err)
}
proc.KillTree(cmd)
dead := false
for i := 0; i < 50; i++ {
if syscall.Kill(pid, 0) != nil {
dead = true
break
}
time.Sleep(20 * time.Millisecond)
}
if !dead {
_ = syscall.Kill(pid, syscall.SIGKILL) // don't leak the sleep in CI
t.Fatalf("backgrounded child %d survived reapTree", pid)
}
}
func TestBashPreservesExplicitNoHupDisown(t *testing.T) {
bashPath, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash not found")
}
dir := t.TempDir()
pidFile := filepath.Join(dir, "pid")
command := "nohup sleep 60 >/dev/null 2>&1 & echo $! > " + shellQuote(pidFile) + "; disown"
out, err := (bash{
shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: bashPath},
}).Execute(context.Background(), argsJSON(t, map[string]any{"command": command}))
if err != nil {
t.Fatalf("bash Execute failed: %v (out=%q)", err, out)
}
data, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("read pid file: %v", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
t.Fatalf("parse backgrounded pid %q: %v", data, err)
}
defer func() { _ = syscall.Kill(pid, syscall.SIGKILL) }()
time.Sleep(200 * time.Millisecond)
if err := syscall.Kill(pid, 0); err != nil {
t.Fatalf("nohup/disown child %d did not survive bash completion: %v", pid, err)
}
}
func TestExplicitBackgroundKeepaliveDetection(t *testing.T) {
tests := []struct {
name string
command string
want bool
}{
{
name: "nohup background",
command: "nohup python train.py >train.log 2>&1 &",
want: true,
},
{
name: "disown background",
command: "sleep 60 >/dev/null 2>&1 & disown",
want: true,
},
{
name: "setsid background",
command: "setsid sleep 60 >/dev/null 2>&1 &",
want: true,
},
{
name: "command wrapper before nohup",
command: "command nohup sleep 60 >/dev/null 2>&1 &",
want: true,
},
{
name: "env assignment wrapper before nohup",
command: "env CUDA_VISIBLE_DEVICES=0 nohup python train.py >/dev/null 2>&1 &",
want: true,
},
{
name: "quoted command name still static",
command: `"nohup" sleep 60 >/dev/null 2>&1 &`,
want: true,
},
{
name: "plain background still reaped",
command: "sleep 60 >/dev/null 2>&1 &",
want: false,
},
{
name: "nohup without background still reaped",
command: "nohup sleep 1",
want: false,
},
{
name: "quoted nohup argument ignored",
command: "echo 'nohup sleep 60 &' &",
want: false,
},
{
name: "process substitution quoted keepalive text ignored",
command: "cat <(printf '%s\\n' 'nohup sleep 60 &')",
want: false,
},
{
name: "process substitution real keepalive command",
command: "cat <(nohup sleep 60 >/dev/null 2>&1 &)",
want: true,
},
{
name: "dynamic command name is not preserved",
command: `cmd=nohup; "$cmd" sleep 60 >/dev/null 2>&1 &`,
want: false,
},
{
name: "parse failure is conservative",
command: "nohup sleep 60 & '",
want: false,
},
{
name: "redirection ampersand ignored",
command: "nohup sleep 1 2>&1",
want: false,
},
{
name: "redirect target before command ignored",
command: "> nohup echo done &",
want: false,
},
{
name: "assignment before nohup",
command: "CUDA_VISIBLE_DEVICES=0 nohup python train.py >/dev/null 2>&1 &",
want: true,
},
{
name: "heredoc body ampersand and parens are not keepalive",
command: strings.Join([]string{
"cat > /tmp/test_redact.go <<'EOF'",
"func main() {",
"\tjson.Unmarshal(data, &v)",
"}",
"EOF",
}, "\n"),
want: false,
},
{
name: "heredoc body keepalive text is not keepalive",
command: strings.Join([]string{
"cat > /tmp/repro.txt <<'EOF'",
"nohup sleep 60 >/dev/null 2>&1 &",
"EOF",
}, "\n"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasExplicitBackgroundKeepalive(tt.command); got != tt.want {
t.Fatalf("hasExplicitBackgroundKeepalive(%q) = %v, want %v", tt.command, got, tt.want)
}
})
}
}
func TestShouldReapAfterRunHonorsExplicitPreserveOnlyOnCompletion(t *testing.T) {
sh := sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}
if shouldReapAfterRun(context.Background(), sh, "sleep 60 >/dev/null 2>&1 &", true) {
t.Fatal("preserve_background_processes should skip reap after normal completion")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if !shouldReapAfterRun(ctx, sh, "sleep 60 >/dev/null 2>&1 &", true) {
t.Fatal("cancelled commands should still reap the process group")
}
}
func TestShellPATHProbeDetachesControllingTerminal(t *testing.T) {
cmd := exec.CommandContext(context.Background(), "sh", "-c", "true")
proc.PrepareShellPATHProbe(cmd)
if cmd.SysProcAttr == nil {
t.Fatal("SysProcAttr is nil")
}
if !cmd.SysProcAttr.Setsid {
t.Fatal("login shell PATH probe should run in a new session so an interactive shell cannot take the TUI foreground")
}
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}