// 后台任务的 kill/等待用 syscall.Kill 驱动,Windows 上没有这个调用。 //go:build !windows package tools import ( "context" "os" "path/filepath" "regexp" "runtime" "strings" "syscall" "testing" "time" "unicode/utf8" "git.gopher.ink/mirrors2/AutoZero/scratch" ) var jobIDRe = regexp.MustCompile(`\b(bg-[0-9a-f]{8}|bg-\d+)\b`) // resetBG 清空进程级作业表。作业表故意跨回合共享(同一次 a2go 进程里都看得见), // 所以测试必须自己隔开,否则前一个用例的作业会出现在后一个用例的列表里。 func resetBG(t *testing.T) { t.Helper() bgMu.Lock() bgJobs = map[string]*bgJob{} bgSeq = nil bgMu.Unlock() } func bgStart(t *testing.T, command string) (id, logPath string) { t.Helper() tl := lookupTool(t, "bash_bg") res, err := tl.Run(context.Background(), `{"action":"start","command":`+jsonQuote(command)+`}`) if err != nil { t.Fatalf("start: %v", err) } id = jobIDRe.FindString(res.Text) if id == "" { t.Fatalf("没拿到作业号:%q", res.Text) } line := regexp.MustCompile(`log: (\S+)`).FindStringSubmatch(res.Text) if line == nil { t.Fatalf("没给出日志路径:%q", res.Text) } return id, line[1] } func jsonQuote(s string) string { // 只在测试里用最省事的方式造 JSON 字符串字面量。 return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"` } func bgStatus(t *testing.T, args string) string { t.Helper() tl := lookupTool(t, "bg_status") res, err := tl.Run(context.Background(), args) if err != nil { t.Fatalf("bg_status: %v", err) } return res.Text } // TestBackgroundJobPushesCompletion:作业结束要主动通知启动它的会话, // 带上作业号、退出状态、日志路径与输出尾部;模型不必再轮询。 func TestBackgroundJobPushesCompletion(t *testing.T) { resetBG(t) t.Setenv("HOME", t.TempDir()) done := make(chan BackgroundDone, 1) j, err := startJob(t.TempDir(), "printf 'hello-bg'", "sess-42", func(ev BackgroundDone) { done <- ev }) if err != nil { t.Fatal(err) } select { case ev := <-done: if ev.JobID != j.ID || ev.SessionID != "sess-42" { t.Fatalf("notification identity: %+v (job %s)", ev, j.ID) } if ev.Exit != "exit 0" { t.Fatalf("exit = %q", ev.Exit) } if !strings.Contains(ev.Tail, "hello-bg") { t.Fatalf("tail missing output: %q", ev.Tail) } if ev.LogPath == "" { t.Fatal("log path missing") } case <-time.After(5 * time.Second): t.Fatal("no completion notification") } } // TestBackgroundJobNotifiesOnFailure:非零退出同样推送,状态要如实。 func TestBackgroundJobNotifiesOnFailure(t *testing.T) { resetBG(t) t.Setenv("HOME", t.TempDir()) done := make(chan BackgroundDone, 1) if _, err := startJob(t.TempDir(), "echo boom >&2; exit 3", "s1", func(ev BackgroundDone) { done <- ev }); err != nil { t.Fatal(err) } select { case ev := <-done: if !strings.Contains(ev.Exit, "3") { t.Fatalf("exit = %q", ev.Exit) } if !strings.Contains(ev.Tail, "boom") { t.Fatalf("tail missing stderr: %q", ev.Tail) } case <-time.After(5 * time.Second): t.Fatal("no completion notification") } } // TestBashBgStartReturnsImmediately:start 不等命令跑完。 // 这是这条通道存在的理由——长任务不该占着 120 秒的同步窗口。 func TestBashBgStartReturnsImmediately(t *testing.T) { resetBG(t) t.Setenv("HOME", t.TempDir()) start := time.Now() id, logPath := bgStart(t, "sleep 30") if el := time.Since(start); el > 2*time.Second { t.Errorf("start 阻塞了 %v,后台通道等于没有", el) } defer func() { _, _ = logPath, id }() // 日志必须落在 scratch 根里:那才是 read 工具够得着的地方(围栏内)。 if !strings.HasPrefix(logPath, filepath.Clean(scratch.Dir())) { t.Errorf("日志不在 scratch 根内:%s (scratch=%s)", logPath, scratch.Dir()) } out := bgStatus(t, `{"id":`+jsonQuote(id)+`}`) if !strings.Contains(out, "running") { t.Errorf("状态应是 running:%q", out) } // 收尾:停掉,别留 sleep 30 给测试进程。 tl := lookupTool(t, "bash_bg") if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil { t.Fatal(err) } } // TestBashBgStopKillsWholeTree:stop 必须带走作业派生的子进程。 // 只杀 `sh -c` 本体的话,`sleep 60 &` 会作为孤儿继续跑(bash 工具那类挂死的 // 同一根源),模型看到的"已停止"和实际的 CPU/网络占用就对不上。 func TestBashBgStopKillsWholeTree(t *testing.T) { resetBG(t) if runtime.GOOS == "windows" { t.Skip("依赖 POSIX 进程组语义") } t.Setenv("HOME", t.TempDir()) // 后台孙子 + 前台本体;把孙子的 pid 写进日志,供事后验尸。 id, logPath := bgStart(t, "sleep 60 & echo $! ; sleep 60") // 先等孙子真的起来(日志里出现 pid)。先 stop 再找 pid 的话,stop 可能早于 // echo 执行,日志永远是空的,测试就什么都没验证。 deadline := time.Now().Add(5 * time.Second) pid := 0 for time.Now().Before(deadline) && pid == 0 { if data, err := os.ReadFile(logPath); err == nil { if n, ok := firstPID(string(data)); ok { pid = n } } time.Sleep(20 * time.Millisecond) } if pid == 0 { t.Fatal("日志里读不到孙子 pid,无法验证进程树") } if err := syscall.Kill(pid, 0); err != nil { t.Fatalf("孙子进程 %d 本该在跑:%v", pid, err) } tl := lookupTool(t, "bash_bg") if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil { t.Fatal(err) } // stop 之后连孙子一起没:只杀 sh 本体的话它就变成孤儿。 deadline = time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if syscall.Kill(pid, 0) != nil { return } time.Sleep(20 * time.Millisecond) } t.Errorf("孙子进程 %d 在 stop 之后仍在运行:没带走进程组", pid) } // TestBashBgCapturesOutputAndReportsExit:作业结束要报退出原因, // 输出要能取回(含中文,验证尾部截断不切字符)。 func TestBashBgCapturesOutputAndReportsExit(t *testing.T) { resetBG(t) t.Setenv("HOME", t.TempDir()) id, _ := bgStart(t, "printf '第一段\\n'; sleep 0.2; printf '中文输出行\\n'") deadline := time.Now().Add(15 * time.Second) var out string for time.Now().Before(deadline) { out = bgStatus(t, `{"id":`+jsonQuote(id)+`,"tail":4000}`) if strings.Contains(out, "exited") { break } time.Sleep(50 * time.Millisecond) } if !strings.Contains(out, "exited (exit 0)") { t.Errorf("应报退出:\n%s", out) } for _, want := range []string{"第一段", "中文输出行"} { if !strings.Contains(out, want) { t.Errorf("输出尾部缺 %q:\n%s", want, out) } } } // TestBgTailSnapsToRuneBoundary:尾部起点落在汉字中间时必须往后推到首字节, // 否则送给模型的是半个字(进 JSON 就是替换字符)。 func TestBgTailSnapsToRuneBoundary(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "job.log") body := strings.Repeat("中文测试行", 2000) // 每个汉字 3 字节 if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } j := &bgJob{LogPath: path} // 逐个 tail 长度扫一遍:任何一种切法都必须产出合法 UTF-8。 for n := 1; n < 40; n++ { got := j.tail(n) if !utf8.ValidString(got) { t.Fatalf("tail=%d 切出非法 UTF-8:%q", n, got) } } if !strings.HasPrefix(j.tail(10), "…") { t.Error("被截断的尾部应带省略号,提示模型前面还有内容") } } // TestBgTailHandlesBigLog:后台作业最常见的正是下载/构建,进度条能刷出几十 MB。 // 取尾部只许读末尾那一段(实现走 ReadAt,见 bashbg.go)。 func TestBgTailHandlesBigLog(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "big.log") f, err := os.Create(path) if err != nil { t.Fatal(err) } line := strings.Repeat("x", 1024) + "\n" for i := 0; i < 20000; i++ { // ~20MB if _, err := f.WriteString(line); err != nil { t.Fatal(err) } } if _, err := f.WriteString("最后一行\n"); err != nil { t.Fatal(err) } _ = f.Close() j := &bgJob{LogPath: path} start := time.Now() got := j.tail(bgDefaultTail) if el := time.Since(start); el > time.Second { t.Errorf("取尾部花了 %v,疑似把整个日志读进内存", el) } if !strings.Contains(got, "最后一行") { t.Errorf("尾部没带上最后一行:%q", got) } } // TestBashBgListAndUnknownID:不传 id 是列表;查不存在的 id 要自解释。 func TestBashBgListAndUnknownID(t *testing.T) { resetBG(t) t.Setenv("HOME", t.TempDir()) out := bgStatus(t, `{}`) if !strings.Contains(out, "no background jobs") { t.Errorf("空表提示:%q", out) } t.Cleanup(func() { resetBG(t) }) id, _ := bgStart(t, "sleep 5") out = bgStatus(t, `{}`) if !strings.Contains(out, id) || !strings.Contains(out, "job(s)") { t.Errorf("列表里应有该作业:%q", out) } out = bgStatus(t, `{"id":"bg-deadbeef"}`) if !strings.Contains(out, "no such job") { t.Errorf("未知 id 应说明:\n%s", out) } // 收尾 tl := lookupTool(t, "bash_bg") _, _ = tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`) } // TestBackgroundToolsRegistered:工具必须在内置表里,且语义标注正确—— // 标注错了,轮询输出就会每次都弹确认(后台的意义当场归零)。 func TestBackgroundToolsRegistered(t *testing.T) { reg := NewRegistry() for _, tl := range Builtin() { if err := reg.Register(tl); err != nil { t.Fatal(err) } } start, ok := reg.Get("bash_bg") if !ok { t.Fatal("bash_bg 没注册") } if !start.SideEffect || start.Kind != KindExec { t.Errorf("bash_bg 必须是副作用 + 执行类:%+v", start) } status, ok := reg.Get("bg_status") if !ok { t.Fatal("bg_status 没注册") } if !status.ReadOnly || !status.ConcurrencySafe || status.SideEffect { t.Errorf("bg_status 必须是只读可并发(轮询不该要审批):%+v", status) } } // firstPID 从日志里取第一个数字串当 pid。 func firstPID(s string) (int, bool) { for _, f := range strings.Fields(s) { n := 0 for _, r := range f { if r < '0' || r > '9' { goto next } n = n*10 + int(r-'0') } if n > 0 { return n, true } next: } return 0, false }