llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
137 lines
4.5 KiB
Go
137 lines
4.5 KiB
Go
// 这几条用例靠给进程组发信号验证"超时带走整棵树",是 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
|
||
}
|