Files
HomeAgent/internal/plugin/proc/grandchild_test.go
JianFeeeee ceeef0b4e1 fix(proc): 杀插件进程组 + readerWG 超时兜底 —— 修孙进程拖死关停
## 现象

线上关停必超时:systemd 报 `State 'stop-sigterm' timed out. Killing.`,
进程组里 23 个插件全退完了,最后那条 `[homed] stopped` 仍打不出来。
其中只有 bili 报 `[proc] bili SIGKILL 后 2s 仍未被收割`,之后近 90 秒无日志。

## 根因

bili 用 exec.Command 拉 yt-dlp(源码 example/bili/plugin.go:122/212),
无 CommandContext、无 Setpgid、Stop() 是空的。yt-dlp 再 fork ffmpeg,
**孙进程继承插件的 stdout 管道写端**。

  插件被 SIGKILL → 孙进程仍存活、写端不关
  → readLoop 的 scanner.Scan() 永不 EOF
  → p.readerWG.Wait() 永不返回(Kill 的最后一行,**无超时**)
  → StopAll 的 wg.Wait() 永不返回 ⇒ 关停挂死 ⇒ systemd SIGKILL

内核 process.go:340 的注释早已预警过这个场景("插件 fork 的孙子进程继承
同一 stdout 写端时,插件本体死了 EOF 也不会到"),但 Kill 没有对应保护。

## 内核三处修法(缺任一条都不够)

1. **spawn 时 Setpgid**:插件自成进程组,不再与内核同组
2. **Kill 杀整个进程组**(kill(-pgid)):孙进程一起死,管道写端才关。
   兜底:负 pid 失败时退回杀本体(老插件/非 Unix 平台)
3. **readerWG.Wait() 加超时兜底**:这是唯一能保证 Kill 一定返回的地方。
   超时后主动关读端逼 readLoop 退出,再兜一层仍不退就放弃等待 ——
   宁可少等 2 秒,也不能把关停无限期挂住。

## 插件侧(bili)

CommandContext + Setpgid + Stop() 里 cancel 并 wait:
- 只 cancel 不 wait 的话内核会先释放共享段,而 yt-dlp 还在写 stdout
- waitRunGroup 杀整个进程组(ffmpeg 也在内),不留孤儿

## 判据:5 条 + 3 组变异

判据用**真实模板编译的插件**(复用 buildPluginWithRealTemplate,
与 e2e_template_test 同一条路)+ NewHost 启动,不是自造 shim:
裸 Spawn 没有 Host 建共享内存段,插件握手会报 permission denied。

★ 判据自己踩了三次坑,都由变异/合跑抓出来:
1. 给孙进程也加 Setpgid ⇒ 它逃出插件进程组,kill(-pgid) 杀不到,
   造出假失败(真实场景 yt-dlp 不会脱离进程组)
2. 各测试数全局孙进程数 ⇒ 前一个泄漏的被后一个数进去,
   单跑通过、合跑变红。改为记录基线只关心自己新增的
3. readerWG 超时那条用纯构造 &Process{cmd:nil} ⇒ Kill 第 607 行
   早退,根本走不到那段,撤掉超时照样绿。补了「脱组孙进程」
   场景(Setsid 逃出进程组)才真正覆盖到

变异:去 Setpgid → 判红;只杀本体不杀组 → 判红。

全量 41 包绿。
2026-09-26 16:58:41 +08:00

379 lines
12 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。
func countShimGrandchildren() 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 {
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++
}
}
return n
}
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。
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 写端,走完整路径。
func TestKillReturnsEvenWhenGrandchildSurvives(t *testing.T) {
p, host := spawnGrandchildPlugin(t, "gc4")
defer func() {
_ = p.Close()
host.Close()
// 孙进程可能活下来(setsid 脱组场景),按 pid 精确清理
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。
func countEscapingGrandchildren() int {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
n := 0
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") {
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 超时的成因)")
}
}