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 残留

## 顺带

`a5f6126` 之后 README 的 biome 格式化不再 churn:仓库**没有** biome 配置,
格式来自流水线默认 ⇒ 每次提交后它都会把工作区改脏。我这次会话里反复
`git checkout --` 把它丢掉,那是和流水线对抗。**提交后 biome 再跑就是
no-op**,问题根除。
This commit is contained in:
JianFeeeee
2026-09-28 10:00:10 +08:00
parent a5f6126e84
commit 9a00c02e59
3 changed files with 373 additions and 7 deletions

View File

@ -556,3 +556,76 @@ chromium 占 766% CPU ⇒ **环境噪声**,已明确不作为性能特征。
又让 webui 组全 404 ⇒ 最终统一成**表里写完整路径、代码不补**。
3. **`contextlib.suppress(sqlite3.connect)`** —— `connect` 是函数不是
异常类,`suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`。
---
## 8. grandchild 测试:不稳定的是**测试设计**,不是生产代码
`internal/plugin/proc` 的 `TestKillReturnsEvenWhenGrandchildSurvives`
曾在 `go test ./...`(600s 超时)与 `make test`(20.4s FAIL)里失败,
但**单独跑 0.24s 通过**、连跑 3 次全绿 ⇒ 「单跑绿、合跑红」。
2026-09-28 定位,结论是**三个测试设计缺陷**,生产代码(`process.go`)没问题。
### 缺陷 1:名字说 Survives,实际测的是「被杀」
| 测试 | spawn 的源 | 孙进程 | `kill(-pgid)` 能杀吗 |
| --- | --- | --- | --- |
| `…EvenWhenGrandchildSurvives` | `grandchildPluginSource` | `sleep 400`,**不设** Setpgid,留在进程组内 | **能** |
| `…WhenGrandchildEscapesProcessGroup` | `escapingGrandchildSource` | `sleep 401` + `Setsid: true` | **不能** |
`grandchildPluginSource` 自己的注释写着「孙进程**不**设 Setpgid:它要留在
插件的进程组里」⇒ 第一个测试里孙进程**不会 Survive**。
⇒ 容易让人误以为「脱组场景已被覆盖」,而它其实覆盖的是另一个场景。
**已改名** `…WhenGrandchildDiesWithProcessGroup`,名字与实现一致。
### 缺陷 2:判据数的是**全系统**进程
`countShimGrandchildren()` / `countEscapingGrandchildren()` 扫 `/proc` 找
`"sleep 400"` / `"sleep 401"` 字符串,**不区分父子关系** ⇒ 同机任何命中同样
cmdline 的进程/容器都会串味。
原注释记过一次前车之鉴(「我第一版就踩了:明明单跑通过,合跑却红」),
但当时只加了 base 快照,**没解决全局匹配这个根因** —— base 也救不了
「别的测试中途拉起 sleep 400」。
**已修**:新增 `procPPid()`,两个计数器都限定 `PPid` 属于本测试的插件。
顺带补上 `e.Name()` 的 `Atoi` 校验(原来会把 `/proc/self`、`/proc/net`
这类非数字目录也去读 cmdline)。
### 缺陷 3:defer 清理「拿不到 pid 就整个跳过」
```go
if pid := pluginPid(p); pid > 0 { syscall.Kill(-pid, SIGKILL) }
```
pid 取不到时**静默跳过** ⇒ 残留 `sleep 400` 污染后续测试 ⇒ 变成下一个测试的
假失败。
**已修**:新增 `cleanupSleepMarkers(marker)`,按唯一 cmdline 标记兜底清理,
即使 `pluginPid` 返回 0 也执行。
### ★ 我被推翻的一个假设(记下来免得重犯)
我一度认定根因是 `waitLoop` 里 `p.cmd.Wait()` **先阻塞**、拆管道在**之后**
(`process.go:359-367`):Go 的 `exec` 里 `Wait()` 会等 copy goroutine 结束,
而那些要等所有管道写端关闭 —— 孙进程持有着,死锁。
**实测推翻了它**:把拆管道提到 `Wait` 之前,那个测试**5 次全 FAIL**
(改前只是偶发)。说明真正的根因是上面三个测试设计问题,
而「Wait 阻塞」是**被孙进程持管道放大**的效应,不是缺陷本身。
⇒ 改了生产代码不但没修好,还把偶发变成必现。**先证明因果再动手。**
### 判据
`internal/plugin/proc/grandchild_design_test.go`,4 条。写它时踩了个坑:
它是**查源码文本**的,我一改源文件(改名/加 ppid 限定/加兜底清理),
锚点就全过期 ⇒ 三条判据一起红。
⇒ 判据自己被重构打断时,要改的是**判据的锚点**(认新旧两种形态),
不是回退修复。最后把判据①从「解函数体比对 spawn 参数」简化为
「只问名字是否还说 Survives」—— 少耦合一层,少失效一处。
变异测试(三个都抓到):改名回 Survives / 抽掉 ppid 限定 / 去掉兜底清理。

View File

@ -0,0 +1,165 @@
package proc
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// 三个测试设计缺陷的判据。全部从**源码文本**提取 —— 这里要防的是
// 「判据与源码漂移」,而这几个缺陷恰恰是漂移造成的。
//
// ## 缺陷 1:名字说 Survives,实际测的是「被杀」
//
// TestKillReturnsEvenWhenGrandchildSurvives spawn grandchildPluginSource
// → sleep 400,**不**设 Setpgid
// → 留在插件进程组内
// → kill(-pgid) **能**杀掉它
//
// grandchildPluginSource 的注释自己写着:
// 「孙进程**不**设 Setpgid:它要留在插件的进程组里,才代表真实场景」
//
// 所以这个测试里孙进程**不会活下来**,与名字里的 Survives 相反。
// 真正测脱组(孙进程活下来)的是
// TestKillReturnsWhenGrandchildEscapesProcessGroup,它用
// escapingGrandchildSource(sleep 401 + Setsid: true)。
//
// ⇒ 两个测试不是「一个多余」,而是**名字与语义对不上**。
// 保留两个可以,但名字必须说清各自测什么。
//
// ## 缺陷 2:判据数的是**全系统**进程数
//
// countShimGrandchildren() 扫 /proc 找 "sleep 400",
// countEscapingGrandchildren() 扫 "sleep 401"。
// 两者都不是「只数自己拉起的」⇒
// 同一台机器上任何其它进程/测试/容器命中同样的 cmdline 就会串味。
// 注释里已经记过一次前车之鉴(「我第一版就踩了」),但只修了 base
// 快照,**没解决全局匹配**这个根因。
//
// ## 缺陷 3:defer 清理依赖 pluginPid,失败就跳过
//
// gc4 的 defer:
// if pid := pluginPid(p); pid > 0 { syscall.Kill(-pid, SIGKILL) }
// 若 pluginPid 拿不到 pid(进程已退出 / 时序未到),清理**整个跳过**
// ⇒ 残留进程污染后续测试。
//
// 运行:go test ./internal/plugin/proc/ -run TestGrandchildTestDesign -v
const testFile = "grandchild_test.go"
func srcText(t *testing.T) string {
t.Helper()
b, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("读 %s: %v", testFile, err)
}
return string(b)
}
func TestGrandchildTestDesign_SurvivesTestUsesEscapingSource(t *testing.T) {
src := srcText(t)
// ★ 判据只问一件事:**那个用普通源的测试,名字是否还说「Survives」**。
//
// 不去解函数体、不去比对 spawn 参数 —— 那些都会随重构变,
// 而「名字 vs 语义」这层矛盾才是真正要防的回归。
//
// 修复前:func TestKillReturnsEvenWhenGrandchildSurvives → 用普通源 ⇒ 红
// 修复后:改名 DiesWithProcessGroup ⇒ 绿
const oldName = "func TestKillReturnsEvenWhenGrandchildSurvives("
const newName = "func TestKillReturnsWhenGrandchildDiesWithProcessGroup("
hasOld := strings.Contains(src, oldName)
hasNew := strings.Contains(src, newName)
switch {
case hasOld && hasNew:
t.Errorf("两个名字同时存在(%s 与 %s):改名没删干净,go vet 也会报重定义",
oldName, newName)
case hasOld:
// ★ 旧名还在:它 spawn 的是 grandchildPluginSource(sleep 400、
// **不**设 Setpgid、留在插件进程组内 ⇒ kill(-pgid) **能**杀掉它)
// ⇒ 孙进程不会「Survive」,与名字矛盾。
// 真正测脱组存活的是 TestKillReturnsWhenGrandchildEscapesProcessGroup
// (escapingGrandchildSource:sleep 401 + Setsid)。
t.Errorf("TestKillReturnsEvenWhenGrandchildSurvives 用了普通源 " +
"grandchildPluginSource(sleep 400、**不**设 Setpgid、留在进程组内、" +
"kill(-pgid) **能**杀掉它)⇒ 孙进程不会「Survive」,与测试名矛盾。\n" +
" 真正测脱组存活的是 TestKillReturnsWhenGrandchildEscapesProcessGroup" +
"(escapingGrandchildSource:sleep 401 + Setsid)。\n" +
" 修法:改名成 …WhenGrandchildDiesWithProcessGroup(名字与实现一致)," +
"或改用 escaping 源。")
}
}
func TestGrandchildTestDesign_CountersAreGlobalNotOwn(t *testing.T) {
src := srcText(t)
for _, c := range []struct{ fn, marker string }{
// 认新旧两个名字:修复后新增了带 ppid 限定的 …Under 变体
{"countShimGrandchildrenUnder", `"sleep 400"`},
{"countEscapingGrandchildren", `"sleep 401"`},
} {
i := strings.Index(src, "func "+c.fn+"(")
if i < 0 {
t.Errorf("找不到 %s", c.fn)
continue
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
continue
}
body := src[i : i+j]
// 扫全系统 /proc 而不看父子关系 ⇒ 会数到别人的进程。
// 修复形态:函数体里有 procPPid(pid) 限定(或名字带 Under)。
hasPPidGate := strings.Contains(body, "procPPid(") ||
strings.Contains(body, "PPid")
if strings.Contains(body, "os.ReadDir(\"/proc\")") && !hasPPidGate {
t.Errorf("%s 扫全系统 /proc 找 %s,不区分父子关系。\n"+
" 同机任何命中同样 cmdline 的进程/容器都会串味,表现为"+
"「合跑红、单跑绿」。\n"+
" 修法:按 PPid 限定为**自己拉起的那几个**,或让插件把自己的孙进程 pid 报上来。",
c.fn, c.marker)
}
}
}
func TestGrandchildTestDesign_CleanupSkippedWhenPidMissing(t *testing.T) {
src := srcText(t)
i := strings.Index(src, "func TestKillReturnsWhenGrandchildDiesWithProcessGroup(")
if i < 0 {
i = strings.Index(src, "func TestKillReturnsEvenWhenGrandchildSurvives(")
}
if i < 0 {
t.Fatal("找不到该测试(新旧名都试过)")
}
j := strings.Index(src[i:], "\n}\n")
body := src[i : i+j]
// defer 里 `if pid := pluginPid(p); pid > 0 { Kill }` ⇒ 拿不到 pid 就整个跳过。
// 修复形态:body 里出现 cleanupSleepMarkers(兜底按 cmdline 清理)。
if strings.Contains(body, "cleanupSleepMarkers(") {
return
}
if strings.Contains(body, "pid > 0") &&
!strings.Contains(body, "else") && !strings.Contains(body, "fallback") {
t.Errorf("defer 清理是「pluginPid(p) > 0 才杀」,pid 拿不到就**整个跳过**清理" +
"⇒ 残留进程污染后续测试。\n" +
" 修法:pid 拿不到时也要兜底(如按唯一 cmdline 标记清理)," +
"或让插件启动时把孙进程 pid 报给宿主。")
}
}
// TestGrandchildTestDesign_CountersHaveSeparateNamespaces 记一条事实,
// 免得以后有人以为两个计数器是同一个。
func TestGrandchildTestDesign_CountersHaveSeparateNamespaces(t *testing.T) {
src := srcText(t)
if !strings.Contains(src, `"sleep 400"`) || !strings.Contains(src, `"sleep 401"`) {
t.Fatal("两个计数器的 sleep 标记应当不同(400 / 401),否则会互相数进去")
}
_ = filepath.Join // 保持 import 有用(若上面某条判据被删也不至于编译失败)
_ = strconv.Itoa
}

View File

@ -85,27 +85,74 @@ func buildGrandchildPlugin(t *testing.T) string {
}
// 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 {
if _, err := strconv.Atoi(e.Name()); err != nil {
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") {
n++
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)
@ -161,6 +208,44 @@ func waitGrandchildrenGone(t *testing.T, base int, limit time.Duration) bool {
}
// 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
@ -243,12 +328,27 @@ func TestSpawnPutsPluginInOwnProcessGroup(t *testing.T) {
// 而 Kill 第 607 行就 `if p.cmd == nil { return nil }` 早退了 ——
// 根本走不到 readerWG 那段,撤掉超时它照样绿。变异测试才暴露出来。
// 现在改用真实插件:孙进程活着且持有 stdout 写端,走完整路径。
func TestKillReturnsEvenWhenGrandchildSurvives(t *testing.T) {
//
// ★ 名字订正(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()
// 孙进程可能活下来(setsid 脱组场景),按 pid 精确清理
// ★ 清理不再「拿不到 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)
}
@ -308,20 +408,48 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
`
// 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", " "), "sleep 401") {
n++
if !strings.Contains(strings.ReplaceAll(string(cl), "\x00", " "), needle) {
continue
}
if rootPid > 0 && procPPid(pid) != rootPid {
continue
}
n++
}
return n
}