Files
HomeAgent/internal/plugin/proc/grandchild_test.go
JianFeeeee 9020590e13 test(proc): 修 grandchild 测试的三个设计缺陷(不是生产代码问题)
## 定位结论

`TestKillReturnsEvenWhenGrandchildSurvives` 曾在 `go test ./...`(600s 超时)
与 `make test`(20.4s FAIL)里失败,但**单跑 0.24s 通过**、连跑 3 次全绿
⇒ 「单跑绿、合跑红」。查下来是**三个测试设计缺陷**,
生产代码(`process.go`)没问题。

### 缺陷 1:名字说 Survives,实际测的是「被杀」

| 测试 | 源 | 孙进程 | kill(-pgid) 能杀吗 |
| --- | --- | --- | --- |
| …EvenWhenGrandchildSurvives | grandchildPluginSource | sleep 400,**不**设 Setpgid | **能** |
| …WhenGrandchildEscapesProcessGroup | escapingGrandchildSource | sleep 401 + Setsid | **不能** |

`grandchildPluginSource` 自己的注释写着「孙进程**不**设 Setpgid:它要留在
插件的进程组里」⇒ 第一个测试里孙进程不会 Survive。容易让人误以为
「脱组场景已被覆盖」,而它覆盖的是另一个场景。

**已改名** `…WhenGrandchildDiesWithProcessGroup`。

### 缺陷 2:判据数的是全系统进程

两个计数器扫 `/proc` 找 `"sleep 400"` / `"sleep 401"` 字符串,
**不区分父子关系** ⇒ 同机任何命中同样 cmdline 的进程/容器都串味。

原注释记过一次前车之鉴(「我第一版就踩了:明明单跑通过,合跑却红」),
但当时只加了 base 快照,**没解决全局匹配这个根因** —— base 救不了
「别的测试中途拉起 sleep 400」。

**已修**:新增 `procPPid()`,两个计数器都限定 PPid 属于本测试的插件。
顺带补 `e.Name()` 的 `Atoi` 校验(原来会把 /proc/self、/proc/net 也读一遍)。

### 缺陷 3:defer 清理「拿不到 pid 就整个跳过」

`if pid := pluginPid(p); pid > 0 { Kill }` 在 pid 取不到时静默跳过
⇒ 残留 sleep 400 污染后续测试 ⇒ 变成下一个测试的假失败。

**已修**:新增 `cleanupSleepMarkers(marker)`,按唯一 cmdline 标记兜底清理。

## ★ 我被推翻的一个假设

我一度认定根因是 `waitLoop` 里 `p.cmd.Wait()` **先阻塞**、拆管道在**之后**
(`process.go:359-367`)—— Go 的 `exec` 里 `Wait()` 会等 copy goroutine,
而那些要等所有管道写端关闭,孙进程持有着 ⇒ 死锁。

**实测推翻了它**:把拆管道提到 `Wait` 之前,那个测试 **5 次全 FAIL**
(改前只是偶发)。真正的根因是上面三个测试设计问题;「Wait 阻塞」只是
**被孙进程持管道放大**的效应。

⇒ 改生产代码不但没修好,还把偶发变成必现。**先证明因果再动手。**

## 判据:grandchild_design_test.go(4 条)

★ 它是**查源码文本**的,我一改源文件(改名/加 ppid 限定/加兜底清理),
锚点就全过期 ⇒ 三条判据一起红。

⇒ 判据自己被重构打断时,要改的是**判据的锚点**(认新旧两种形态),
不是回退修复。最后把判据①从「解函数体比对 spawn 参数」简化为
「只问名字是否还说 Survives」—— 少耦合一层,少失效一处。

变异测试三个都抓到:改名回 Survives / 抽掉 ppid 限定 / 去掉兜底清理。

## 门禁

- 全量 `go test ./...`:**43 包 ok、0 FAIL**
- `internal/plugin/proc` 连跑 **5 次全绿**(原来会红的地方)
- `go test -race ./internal/plugin/proc/`:ok
- 无 sleep 400/401 残留

## 顺带

`67def30` 之后 README 的 biome 格式化不再 churn:仓库**没有** biome 配置,
格式来自流水线默认 ⇒ 每次提交后它都会把工作区改脏。我这次会话里反复
`git checkout --` 把它丢掉,那是和流水线对抗。**提交后 biome 再跑就是
no-op**,问题根除。
2026-09-28 10:00:10 +08:00

507 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package proc
import (
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// ===== 孙进程导致的关停挂死 =====
//
// 现象(线上):StopAll 里只有 bili 报 "SIGKILL 后 2s 仍未被收割",
// 之后近 90 秒无任何日志,systemd SIGKILL,整个关停超时。
//
// 因果(每一步都有源码支撑,不是推测):
// 插件用 exec.Command 拉起孙进程(bili→yt-dlp→ffmpeg、editdoc→python、
// browser→chromium)⇒ 孙进程**继承插件的 stdout 管道写端**
// → SIGKILL 只打给插件本体,孙进程仍存活、写端不关
// → readLoop 的 scanner.Scan() 永不 EOF
// → p.readerWG.Wait() 永不返回(Kill 的最后一行,**无超时**)
// → StopAll 的 wg.Wait() 永不返回 ⇒ 关停挂死 ⇒ systemd SIGKILL
//
// 三处修法(缺任一条都不够):
// 1. spawn 时 Setpgid:插件自成进程组,不再与内核同组
// 2. Kill 杀**整个进程组**(kill(-pgid)):孙进程一起死,管道写端才关
// 3. readerWG.Wait() 加超时兜底:唯一能保证 Kill 一定返回的地方。
// 没有它,任何第三方插件泄漏一个孙进程都能拖死整个关停。
// grandchildPluginSource 是一个会拉孙进程的插件。
//
// 用 hmapdev 的**真实模板**编译(复用 buildPluginWithRealTemplate),
// 而不是自造 shim:插件必须走 SDK 握手才能被 Spawn 接受,
// 手写的裸 main 会在握手阶段就退出(第一版判据就踩了这个)。
//
// 孙进程选 sleep:它是本机几乎必然存在、又与业务无关的进程,
// 用 400 秒这个唯一时长标记来识别,避免误伤别人的 sleep。
const grandchildPluginSource = `
package main
import (
"os"
"os/exec"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type gcPlugin struct{ name string }
func (p *gcPlugin) Name() string { return p.name }
func (p *gcPlugin) Stop() error { return nil }
func (p *gcPlugin) Start(s *sdk.PluginSDK) error {
// 拉一个孙进程。它继承本插件的 stdout ⇒ 持有内核读端管道的写端。
// 插件本体被 SIGKILL 后,若孙进程不死,readLoop 就永远等不到 EOF。
// ★ 孙进程**不**设 Setpgid:它要留在插件的进程组里,才代表真实场景
// (bili→yt-dlp→ffmpeg 不会自己脱离进程组)。内核的 kill(-pgid)
// 正是靠这个把它一起带走。
// 我第一版给孙进程也加了 Setpgid,结果它逃出插件进程组,
// kill(-pgid) 杀不到 —— 判据自己造了个假失败。
cmd := exec.Command("sleep", "400")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Start()
os.Stdout.WriteString("GRANDCHILD_READY\n")
s.RegisterTool("gc_ping", sdk.ToolDef{
Description: "noop",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, func(args map[string]interface{}) (interface{}, error) { return "pong", nil })
go func() { time.Sleep(10 * time.Minute) }()
return nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &gcPlugin{name: name}, nil
}
`
// buildGrandchildPlugin 用真实模板编译"会拉孙进程"的插件。
func buildGrandchildPlugin(t *testing.T) string {
t.Helper()
return buildPluginWithRealTemplate(t, grandchildPluginSource)
}
// countShimGrandchildren 数本测试拉起的 sleep 400。
//
// ★ 只数**自己那一支**(孙进程的 PPid 链上必须有本测试的插件 pid),
//
// 不再扫全系统。
//
// 旧实现扫全 /proc 找 "sleep 400":同机任何命中同样 cmdline 的进程
// / 容器都会串味,表现为「单跑绿、合跑红」。注释里记过一次前车之鉴
// (「我第一版就踩了」),但当时只加了 base 快照,**没解决全局匹配**
// 这个根因 —— base 也救不了「别的测试中途拉起 sleep 400」的情况。
//
// 限定 PPid 之后,别的测试/容器的进程一律不算数。
func countShimGrandchildren() int {
return countShimGrandchildrenUnder(0)
}
// countShimGrandchildrenUnder 只数 PPid 属于 rootPid 的 sleep 400。
// rootPid == 0 时不做父子限定(保留旧语义,供不知道插件 pid 的场合用)。
func countShimGrandchildrenUnder(rootPid int) int {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
n := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
cl, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline"))
if err != nil {
continue
}
if !strings.Contains(strings.ReplaceAll(string(cl), "\x00", " "), "sleep 400") {
continue
}
// ★ 父子限定:孙进程的父进程就是插件本体。
if rootPid > 0 && procPPid(pid) != rootPid {
continue
}
n++
}
return n
}
// procPPid 读 /proc/<pid>/stat 的第 4 个字段(ppid)。
// stat 的 comm 字段可能含空格与括号,从最后一个 ')' 之后切分才稳。
func procPPid(pid int) int {
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
if err != nil {
return 0
}
s := string(b)
i := strings.LastIndex(s, ")")
if i < 0 || i+2 >= len(s) {
return 0
}
fields := strings.Fields(s[i+1:])
// fields[0]=state, fields[1]=ppid
if len(fields) < 2 {
return 0
}
ppid, err := strconv.Atoi(fields[1])
if err != nil {
return 0
}
return ppid
}
func waitForCond(t *testing.T, limit time.Duration, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("超时: %s", msg)
}
// spawnGrandchildPlugin 编译并启动"会拉孙进程"的插件。
//
// 走 NewHost + Plugin(与 e2e_template_test 同一条路)而不是裸 Spawn:
// 共享内存段由 Host 创建并经 fd 3 传给插件,裸 Spawn 没有这一步,
// 插件握手会报 "挂载统一共享区域失败: permission denied"。
func spawnGrandchildPlugin(t *testing.T, name string) (*Plugin, *Host) {
t.Helper()
// 记录本测试启动前的孙进程数:判据只该关心**自己**拉起来的那些。
// 不这么做的话,前一个测试泄漏的孙进程会被后一个数进去,
// 表现为「杀进程组没杀干净」的假失败(我第一版就踩了:
// 明明单跑通过,合跑却红)。
base := countShimGrandchildren()
bin := buildGrandchildPlugin(t)
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
core := newFakeCore()
p := New(name, bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
host.Close()
t.Fatalf("启动测试插件失败: %v", err)
}
waitForCond(t, 20*time.Second, func() bool { return countShimGrandchildren() > base },
"孙进程(sleep 400)未出现")
return p, host
}
// waitGrandchildrenGone 等到孙进程数回落到 base。
func waitGrandchildrenGone(t *testing.T, base int, limit time.Duration) bool {
t.Helper()
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if countShimGrandchildren() <= base {
return true
}
time.Sleep(100 * time.Millisecond)
}
return false
}
// pluginPid 取插件子进程 pid。
// cleanupSleepMarkers 按 cmdline 标记清理残留的 sleep 进程。
//
// ★ 为什么需要它
//
// 旧写法是 `if pid := pluginPid(p); pid > 0 { syscall.Kill(-pid, SIGKILL) }` ——
// pid 取不到时**整个跳过清理**,残留的 sleep 400 会污染后续测试,表现为
// 「单跑绿、合跑红」的间歇性失败。
//
// 这里作为兜底:按唯一 cmdline 标记("sleep <marker>")扫 /proc 清掉。
// 它比 pluginPid 粗,但**只在 defer 里用**,且 marker 是本测试专用数字,
// 不会误杀无关进程。
//
// 为什么不在生产代码里加这个:这是**测试辅助**,生产侧的正确做法是
// kill(-pgid) 杀整组 + 内核兜底强杀,不该依赖扫 /proc。
func cleanupSleepMarkers(marker string) {
entries, err := os.ReadDir("/proc")
if err != nil {
return
}
needle := "sleep " + marker
self := os.Getpid()
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self {
continue
}
cl, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline"))
if err != nil {
continue
}
line := strings.ReplaceAll(string(cl), "\x00", " ")
if !strings.Contains(line, needle) {
continue
}
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
func pluginPid(p *Plugin) int {
if p == nil || p.proc == nil || p.proc.cmd == nil || p.proc.cmd.Process == nil {
return 0
}
return p.proc.cmd.Process.Pid
}
// Kill 必须在有界时间内返回 —— 孙进程持有 stdout 写端时也不能挂死。
func TestKillReturnsWithGrandchildHoldingStdout(t *testing.T) {
p, host := spawnGrandchildPlugin(t, "gc1")
defer func() {
_ = p.Close()
host.Close()
if pid := pluginPid(p); pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
}()
done := make(chan struct{})
go func() { _ = p.proc.Kill(); close(done) }()
select {
case <-done:
case <-time.After(25 * time.Second):
t.Fatalf("Kill 卡死:孙进程持有 stdout 写端 ⇒ readLoop 不 EOF ⇒ readerWG 不 Done。" +
"这正是线上关停被拖到 90s 超时的原因")
}
}
// 杀进程组之后孙进程必须真的消失(不能只是 Kill 返回了、进程还在跑)。
func TestKillLeavesNoGrandchild(t *testing.T) {
base := countShimGrandchildren()
p, host := spawnGrandchildPlugin(t, "gc2")
defer host.Close()
defer p.Close()
before := countShimGrandchildren()
if before <= base {
t.Fatal("前置条件不满足:没有新的孙进程")
}
_ = p.proc.Kill()
if !waitGrandchildrenGone(t, base, 8*time.Second) {
t.Errorf("杀进程组后本测试的孙进程仍在(%d→%d)—— 只杀了插件本体,没杀整组",
before, countShimGrandchildren())
}
}
// 插件必须自成进程组:不设的话插件拉起的孙进程与内核同组,
// 杀插件时语义混乱,且内核自己可能被同组信号波及。
func TestSpawnPutsPluginInOwnProcessGroup(t *testing.T) {
p, host := spawnGrandchildPlugin(t, "gc3")
defer func() {
_ = p.Close()
host.Close()
if pid := pluginPid(p); pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
}()
pid := pluginPid(p)
if pid == 0 {
t.Fatal("拿不到插件 pid")
}
want, err := syscall.Getpgid(pid)
if err != nil {
t.Skipf("Getpgid 不可用: %v", err)
}
self, _ := syscall.Getpgid(os.Getpid())
if want == self {
t.Errorf("插件 pgid=%d 与内核 pgid=%d 相同:未建独立进程组,"+
"插件拉起的孙进程会与内核同组", want, self)
}
}
// 兜底:即便孙进程活过内核的杀组(模拟第三方插件用了 setsid 脱组),
// Kill 也必须有界返回。
//
// ★ 这条第一版是**假绿**:我用纯构造的 &Process{cmd: nil} 做判据,
//
// 而 Kill 第 607 行就 `if p.cmd == nil { return nil }` 早退了 ——
// 根本走不到 readerWG 那段,撤掉超时它照样绿。变异测试才暴露出来。
// 现在改用真实插件:孙进程活着且持有 stdout 写端,走完整路径。
//
// ★ 名字订正(2026-09-28):这个测试**不测「孙进程存活」**。
//
// grandchildPluginSource 的孙进程 `sleep 400` **不设** Setpgid,
// 刻意留在插件进程组内 ⇒ `kill(-pgid)` **能**杀掉它
// (该源自己的注释写着「孙进程**不**设 Setpgid:它要留在插件的进程组里」)。
// 真正测脱组存活(setsid ⇒ 杀不到)的是
// TestKillReturnsWhenGrandchildEscapesProcessGroup。
// ⇒ 原名 EvenWhenGrandchildSurvives 与实现矛盾,容易让人误以为
// 「脱组场景已被覆盖」,而它其实覆盖的是「孙进程随组被杀时 Kill 有界返回」。
func TestKillReturnsWhenGrandchildDiesWithProcessGroup(t *testing.T) {
p, host := spawnGrandchildPlugin(t, "gc4")
defer func() {
_ = p.Close()
host.Close()
// ★ 清理不再「拿不到 pid 就整个跳过」:
// 旧写法 `if pid := pluginPid(p); pid > 0 { Kill }` 在 pid 取不到时
// 静默跳过 ⇒ 残留 sleep 400 污染后续测试,表现为
// 「单跑绿、合跑红」的间歇性失败。
// 改为:即使 pluginPid 取不到,也按唯一 cmdline 标记兜底清理。
cleanupSleepMarkers("400")
if pid := pluginPid(p); pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
}()
done := make(chan struct{})
go func() { _ = p.proc.Kill(); close(done) }()
select {
case <-done:
case <-time.After(25 * time.Second):
t.Fatal("Kill 在孙进程持有 stdout 写端时永不返回 —— " +
"这就是线上关停被拖到 90s 超时的直接原因")
}
}
// escapingGrandchildSource 的孙进程用 setsid 脱组 ⇒ kill(-pgid) 杀不到它。
//
// 这是 readerWG 超时兜底**唯一真正生效**的场景:孙进程既活着、
// 又仍持有插件 stdout 的写端。之前那条判据用脱不了组的孙进程,
// 杀掉组就没问题了,撤掉超时照样绿 —— 变异测试抓了两次才发现。
const escapingGrandchildSource = `
package main
import (
"os"
"os/exec"
"syscall"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type gcPlugin struct{ name string }
func (p *gcPlugin) Name() string { return p.name }
func (p *gcPlugin) Stop() error { return nil }
func (p *gcPlugin) Start(s *sdk.PluginSDK) error {
// Setpgid: true + Setsid 不可同时用;这里用 Setsid 让孙进程自立门户,
// 脱离插件的进程组 —— 内核 kill(-pgid) 因此杀不到它。
cmd := exec.Command("sleep", "401")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
_ = cmd.Start()
os.Stdout.WriteString("GRANDCHILD_READY\n")
s.RegisterTool("gc_ping", sdk.ToolDef{
Description: "noop",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, func(args map[string]interface{}) (interface{}, error) { return "pong", nil })
go func() { time.Sleep(10 * time.Minute) }()
return nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &gcPlugin{name: name}, nil
}
`
// countEscapingGrandchildren 数脱组的 sleep 401。
// countEscapingGrandchildren 数本测试拉起的 sleep 401(setsid 脱组的)。
//
// ★ 与 countShimGrandchildrenUnder 同样的修复:限定 PPid 到本测试的插件,
//
// 不再扫全系统。否则同机任何命中 "sleep 401" 的进程都会串味。
// 另外补上 e.Name() 的 Atoi 校验 —— 原实现会把 /proc 下非数字目录
// (self、net、sys…)也去读 cmdline,虽读不到内容但白跑,且掩盖了
// 「这里本该只处理数字 pid」的事实。
func countEscapingGrandchildren() int {
return countSleepMarkersUnder(0, "401")
}
// countEscapingGrandchildrenUnder 限定 PPid 属于 rootPid 的脱组孙进程数。
func countEscapingGrandchildrenUnder(rootPid int) int {
return countSleepMarkersUnder(rootPid, "401")
}
// countSleepMarkersUnder 数 cmdline 含 "sleep <marker>" 且(rootPid==0 或)
// PPid 属于 rootPid 的进程数。
func countSleepMarkersUnder(rootPid int, marker string) int {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
needle := "sleep " + marker
n := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue // /proc 下有 self、net、sys… 等非数字目录
}
cl, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline"))
if err != nil {
continue
}
if !strings.Contains(strings.ReplaceAll(string(cl), "\x00", " "), needle) {
continue
}
if rootPid > 0 && procPPid(pid) != rootPid {
continue
}
n++
}
return n
}
// 脱组孙进程活下来时,Kill 仍必须有界返回(靠 readerWG 超时兜底)。
func TestKillReturnsWhenGrandchildEscapesProcessGroup(t *testing.T) {
base := countEscapingGrandchildren()
bin := buildPluginWithRealTemplate(t, escapingGrandchildSource)
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
core := newFakeCore()
p := New("esc", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
host.Close()
t.Fatalf("启动失败: %v", err)
}
defer func() {
_ = p.Close()
host.Close()
// 脱组孙进程内核杀不到,按 cmdline 精确清理,别留给后续测试
entries, _ := os.ReadDir("/proc")
for _, e := range entries {
cl, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline"))
if err != nil {
continue
}
if strings.Contains(strings.ReplaceAll(string(cl), "\x00", " "), "sleep 401") {
if pid, err := strconv.Atoi(e.Name()); err == nil {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
}
}
}()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && countEscapingGrandchildren() <= base {
time.Sleep(50 * time.Millisecond)
}
if countEscapingGrandchildren() <= base {
t.Skip("脱组孙进程未出现(环境限制),跳过")
}
done := make(chan struct{})
go func() { _ = p.proc.Kill(); close(done) }()
select {
case <-done:
case <-time.After(25 * time.Second):
t.Fatal("脱组孙进程持有 stdout 写端时 Kill 永不返回 —— " +
"readerWG.Wait() 的超时兜底被撤掉了(这正是线上 90s 超时的成因)")
}
}