mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 13:23:03 +00:00
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:
@ -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 限定 / 去掉兜底清理。
|
||||
|
||||
165
internal/plugin/proc/grandchild_design_test.go
Normal file
165
internal/plugin/proc/grandchild_design_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user