From 2879e768837e9fc1800253695f1d42658eb99ca8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 21:44:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(test):=20=E5=B4=A9=E6=BA=83=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E6=B5=8B=E8=AF=95=E8=AF=AF=E6=9D=80=E5=90=8C=E6=9C=BA?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E6=8F=92=E4=BB=B6=EF=BC=8C=E4=B8=94=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E6=97=A0=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 生产 homed 的 editdoc 子进程从 9-03 起被 SIGKILL 9 次,间隔完全不规律 (126~485 分钟),全部发生在无工具调用的空闲期。查过 OOM(dmesg/ journalctl -k/cgroup oom_kill 全为 0)、systemd 内存限制(MemoryMax= infinity)、cron/timer、内核自身的 StopAll 路径、agent 执行过的 cmd_run 命令,以及 Pdeathsig 绑创建线程的可能——全部排除。 ## 真因:测试杀了生产的进程 用 ftrace 的 signal_generate tracepoint 挂监视器后抓到发送者 cmdline: /tmp/go-build.../plugins.test -test.run=TestRealPlugin_CrashDoesNotKillKernel findPluginPID 用**全系统** `pgrep -f plugin.bin`,然后只比"exe 路径含 editdoc"。生产实例的 /home/newqqagent/plugins/editdoc/plugin.bin 也满足 这个条件,谁先被 pgrep 列出来就杀谁。9 次 kill 全部落在有人跑 go test 的时段——18:18:30 那次正是一轮 `go test ./... -race` 的窗口。 之前几轮排查一直在生产实例内部找原因,方向从一开始就错了:杀手在仓库里。 ## 更严重的是这个测试本身无效 旧断言是"SIGKILL 之后内核仍存活"。可内核本来就活着——即使信号发错了 对象(杀了生产实例的插件),测试内核的插件压根没死,断言照样通过。 **它在测一件没发生的事**,同时把生产环境打坏了,而绿色的测试结果掩盖了 这一切。这也是它能连续 9 次造成生产故障却从没被注意到的原因。 ## 修法 findPluginPID 增加 root 硬约束:/proc//exe 必须以测试自己的 plgDir 为前缀,且是目标插件,两道条件同时成立才算命中。root 为空直接 t.Fatal ——这不是可选过滤器,是防误杀的前提。 - 用 exe 而非 cmdline:cmdline 可被进程自行改写,exe 符链由内核维护。 - root 先过 EvalSymlinks:/tmp 在部分发行版上是符链,不归一化会让前缀 比较永远不命中,退化成静默 Skip(那样测试就白跑了)。 断言改成两步:先轮询确认目标进程真的退出(3s 上限),再验内核未被连带。 两步都成立才能证明隔离生效。 ## 验证 修复后跑 TestRealPlugin_CrashDoesNotKillKernel: - 杀的是 pid=3448366,exe 在 /tmp/hc_integration_3823918128/plugins 下 ✓ - 生产 editdoc pid 测试前后均为 3362892,存活时长连续 ✓ - 四个 TestRealPlugin_* 全部 PASS --- internal/plugins/real_plugin_smoke_test.go | 74 +++++++++++++++++----- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/internal/plugins/real_plugin_smoke_test.go b/internal/plugins/real_plugin_smoke_test.go index 73283ea..b0bd83c 100644 --- a/internal/plugins/real_plugin_smoke_test.go +++ b/internal/plugins/real_plugin_smoke_test.go @@ -213,27 +213,68 @@ func TestRealPlugin_CrashDoesNotKillKernel(t *testing.T) { t.Fatal("editdoc 未加载") } - // 找到插件子进程并 SIGKILL - pid := findPluginPID(t, "editdoc") + // 找插件子进程并 SIGKILL。 + // + // 必须拿 plgDir 限定范围:旧实现用全系统 pgrep -f plugin.bin 后 + // 只比“路径含 editdoc”,于是在跑着生产实例的机器上,它会把 + // /home/newqqagent/plugins/editdoc/plugin.bin 当成目标杀掉(实测 9 次, + // 全部落在有人跑 go test 的时段)。更糟的是此时本测试仍会通过: + // 它断言的是测试内核存活,而那个内核的插件压根没死——**它在测一件 + // 没发生的事**,同时还把生产环境打坏了。 + pid := findPluginPID(t, plgDir, "editdoc") if pid == 0 { - t.Skip("未找到插件子进程(进程名匹配失败)") + t.Skip("未找到本测试自己拉起的插件子进程") } - t.Logf("kill 插件进程 pid=%d", pid) + t.Logf("kill 插件进程 pid=%d (exe 在 %s 下)", pid, plgDir) if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { t.Fatalf("kill: %v", err) } - // 内核必须存活并能继续工作 - time.Sleep(300 * time.Millisecond) + // 先确认目标进程真的死了。 + // + // 这步不能省:旧版直接断言“内核存活”,而内核本来就活着—— + // 即使 SIGKILL 发错了对象(杀了生产实例的插件)测试也会结束。 + // 先验“目标真死”再验“内核未被连带”,两步都成立才能证明隔离生效。 + deadline := time.Now().Add(3 * time.Second) + dead := false + for time.Now().Before(deadline) { + if syscall.Kill(pid, 0) != nil { + dead = true + break + } + time.Sleep(50 * time.Millisecond) + } + if !dead { + t.Fatalf("pid=%d 在 SIGKILL 后 3s 内未退出,崩溃隔离无从验证", pid) + } + + // 内核(本测试进程)必须存活并能继续工作 if env.pluginReg.List() == nil { t.Fatal("内核在插件崩溃后不可用") } - t.Logf("插件崩溃后内核存活,已加载插件数=%d", len(env.pluginReg.List())) + t.Logf("插件进程已确认退出,内核存活,已加载插件数=%d", len(env.pluginReg.List())) } -// findPluginPID 按二进制路径找插件子进程 pid。 -func findPluginPID(t *testing.T, name string) int { +// findPluginPID 在**指定插件目录下**找插件子进程 pid。 +// +// root 参数是硬约束,不是可选过滤器:本函数的唯一用途是给崩溃隔离 +// 测试提供一个“可以安全 SIGKILL 的 pid”,而安全的定义就是它必须属于 +// 本测试自己的临时目录。不带这个约束就会误杀同机生产实例的插件。 +// +// 匹配依据是 /proc//exe 的真实路径必须以 root 为前缀。 +// 用 exe 而不用 cmdline:cmdline 可被进程自行改写,而 exe 符链由内核维护。 +// root 先过一道 EvalSymlinks:/tmp 在部分发行版上是符链(如 macOS 的 +// /tmp -> /private/tmp),不归一化会让前缀比较永远不命中,退化成静默 Skip。 +func findPluginPID(t *testing.T, root, name string) int { t.Helper() + if root == "" { + t.Fatal("findPluginPID: root 不得为空(防止误杀全系统同名插件)") + } + realRoot, err := filepath.EvalSymlinks(root) + if err != nil { + realRoot = root + } + out, err := exec.Command("pgrep", "-f", "plugin.bin").Output() if err != nil { return 0 @@ -244,15 +285,18 @@ func findPluginPID(t *testing.T, name string) int { if pid == 0 { continue } - // 校验 cwd 或 cmdline 含插件名 exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) - if err == nil && strings.Contains(exe, name) { - return pid + if err != nil { + continue } - cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) - if err == nil && strings.Contains(cwd, name) { - return pid + // 两道条件同时成立才算命中:在本测试的目录树内,且是目标插件 + if !strings.HasPrefix(exe, realRoot+string(os.PathSeparator)) { + continue } + if !strings.Contains(exe, name) { + continue + } + return pid } return 0 }