// 这几条用例靠给进程组发信号验证"超时带走整棵树",是 Unix 的把戏; // Windows 没有 syscall.Kill,编译期就该排除(实现本身在 execx_windows.go)。 //go:build !windows package execx import ( "context" "errors" "fmt" "os" "path/filepath" "runtime" "strconv" "strings" "syscall" "testing" "time" "unicode/utf8" ) // TestRunTimeoutKillsDescendant 是这次事故的回归守卫:超时设 1 秒, // 实际挂了 23 分钟——因为只杀了直接子进程,孙子仍握着 stdout 管道写端, // cmd.Run() 卡在输出拷贝上出不来。 func TestRunTimeoutKillsDescendant(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("依赖 POSIX 进程组语义") } const limit = time.Second ctx, cancel := context.WithTimeout(context.Background(), limit) defer cancel() pidFile := filepath.Join(t.TempDir(), "grandchild.pid") // `sleep 60 &` 是关键:它继承命令的 stdout 管道,且不是直接子进程。 script := fmt.Sprintf("sleep 60 & echo $! > %s && sleep 60", strconv.Quote(pidFile)) start := time.Now() out, err := Run(ctx, Options{}, "sh", "-c", script) elapsed := time.Since(start) if err == nil { t.Fatalf("超时本该报错,却成功了(out=%q)", out) } // WaitGrace(2s) 是给"杀完还在收尾"的余量;3 倍预算足够宽松, // 又能稳定抓住"完全没生效"那种几十秒/几十分钟的挂法。 if elapsed > 6*limit { t.Errorf("超时没有生效:预算 %v,实际阻塞 %v", limit, elapsed.Round(time.Millisecond)) } // 孙子必须一起走:留着它就是孤儿进程,会一直吃 CPU/网络直到自己结束。 if pid := readPID(t, pidFile); pid > 0 && syscall.Kill(pid, 0) == nil { t.Errorf("孙子进程 %d 仍在运行(进程树没被带走)", pid) } } // TestRunNoTimeoutOnFastCommand:修超时不能把正常路径改坏。 func TestRunNoTimeoutOnFastCommand(t *testing.T) { out, err := Run(context.Background(), Options{}, "sh", "-c", "printf a; printf b 1>&2") if err != nil { t.Fatalf("err=%v out=%q", err, out) } if got := string(out); got != "ab" { t.Errorf("stdout/stderr 未合并,got=%q", got) } } // TestRunHonorsDirAndStdin:接线点(bash 工具的工作区语义、git 的 -C 目录)依赖这两项。 func TestRunHonorsDirAndStdin(t *testing.T) { dir := t.TempDir() out, err := Run(context.Background(), Options{Dir: dir, Stdin: []byte("hello")}, "sh", "-c", "cat && pwd") if err != nil { t.Fatalf("err=%v", err) } got := strings.TrimSpace(string(out)) // macOS 的 TempDir 常带 /private 前缀,只比尾名不比拼路径。 if !strings.HasPrefix(got, "hello") || filepath.Base(got) != filepath.Base(dir) { t.Errorf("Dir/Stdin 没生效,got=%q want=hello + %q", got, dir) } } // TestRunExitErrorSurvives:非零退出要既拿到输出又拿到错误(bash 工具靠它回灌失败块)。 func TestRunExitErrorSurvives(t *testing.T) { out, err := Run(context.Background(), Options{}, "sh", "-c", "printf oops; exit 3") if err == nil { t.Fatal("exit 3 必须报错") } if !strings.Contains(string(out), "oops") { t.Errorf("失败时的 stdout 丢了:out=%q", out) } if code := exitCode(err); code != 3 { t.Errorf("退出码应为 3,got=%d err=%v", code, err) } } func TestTruncateStaysOnRuneBoundary(t *testing.T) { in := []byte("中文测试") // 4 个汉字 = 12 字节 for _, max := range []int{1, 2, 4, 7, 10, 11, 13, 12} { out, cut := Truncate(in, max) if !utf8.Valid(out) { t.Errorf("max=%d 截出非法 UTF-8:%q", max, out) } if want := max < len(in); cut != want { t.Errorf("max=%d 截断标记应为 %v,got=%v", max, want, cut) } } // max=10 落在「试」的第 2 个字节上:必须退回到 9 字节(3 个完整汉字)。 if out, _ := Truncate(in, 10); string(out) != "中文测" { t.Errorf("got=%q want=中文测", out) } if _, cut := Truncate(in, 0); cut { t.Error("maxBytes<=0 应表示不截断") } } func readPID(t *testing.T, path string) int { t.Helper() // 命令组被整体杀掉时,pid 文件可能压根没写出来(那是更彻底的清理), // 稍等一次再判定,避免把这个合法竞态读成失败。 for i := 0; i < 20; i++ { data, err := os.ReadFile(path) if err == nil { pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) return pid } time.Sleep(50 * time.Millisecond) } return -1 } func exitCode(err error) int { var ee interface{ ExitCode() int } if errors.As(err, &ee) { return ee.ExitCode() } return -1 }