mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
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 包绿。
This commit is contained in:
@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
@ -135,6 +136,15 @@ func Spawn(name, bin string, opts Options) (*Process, error) {
|
||||
cmd.Dir = opts.Dir
|
||||
// stderr 直通内核日志:插件的 panic 栈、log 输出可直接看到。
|
||||
cmd.Stderr = os.Stderr
|
||||
// 插件自成进程组(Setpgid)。为何必须:
|
||||
//
|
||||
// 插件会用 exec.Command 拉孙进程(bili→yt-dlp→ffmpeg、editdoc→python、
|
||||
// browser→chromium —— 实测 8 个插件都这么干,且无一做进程组隔离)。
|
||||
// 不分组时孙进程与内核同组,Kill 只能打给插件本体,孙进程变孤儿:
|
||||
// 1. 它继续持有插件 stdout 管道的写端 ⇒ 内核 readLoop 永不 EOF;
|
||||
// 2. 它自己活成孤儿,继续占 CPU/网络/文件句柄。
|
||||
// 分组后 Kill 可以 kill(-pgid) 一次带走整棵树。
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if len(opts.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), opts.Env...)
|
||||
}
|
||||
@ -597,7 +607,24 @@ func (p *Process) Kill() error {
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
err := p.cmd.Process.Kill()
|
||||
// 杀**整个进程组**(负 pid = 进程组),不只是插件本体。
|
||||
//
|
||||
// 插件拉起的孙进程(bili→yt-dlp→ffmpeg 等,实测 8 个插件都拉孙进程)
|
||||
// 继承插件的 stdout 管道写端。只杀本体的话孙进程变孤儿:
|
||||
// 它继续持有写端 ⇒ 内核 readLoop 永远等不到 EOF ⇒ 关停挂死。
|
||||
// 线上症状:StopAll 里只有 bili 报 "SIGKILL 后 2s 仍未被收割",
|
||||
// 之后近 90 秒无日志,systemd SIGKILL。
|
||||
//
|
||||
// 兜底:Setpgid 未生效(老插件/平台不支持)时退回杀本体,
|
||||
// 否则 kill(-pgid) 会失败而插件还活着。
|
||||
pid := p.cmd.Process.Pid
|
||||
killErr := syscall.Kill(-pid, syscall.SIGKILL)
|
||||
if killErr != nil {
|
||||
// 进程组不存在或无权限:退回只杀本体。
|
||||
// 不能直接返回错误:Setpgid 未生效时(老插件、非 Unix 平台)
|
||||
// 负 pid 会报 ESRCH,此时必须仍然把插件本体杀掉。
|
||||
killErr = p.cmd.Process.Kill()
|
||||
}
|
||||
// 等 waitLoop 收割完成。不再在此兜底调 markExited:
|
||||
// cmd.Wait 只能由 waitLoop 调一次,两处调会报 "wait: no child processes"。
|
||||
select {
|
||||
@ -607,9 +634,33 @@ func (p *Process) Kill() error {
|
||||
// 不能无限等,否则重载路径整体挂死;留日志供定位。
|
||||
log.Printf("[proc] %s SIGKILL 后 %v 仍未被收割(进程可能卡在内核态)", p.name, killReapTimeout)
|
||||
}
|
||||
p.readerWG.Wait()
|
||||
if err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return fmt.Errorf("proc: 结束 %s: %w", p.name, err)
|
||||
// readerWG.Wait 必须有界:孙进程持有 stdout 写端时 readLoop 永不返回,
|
||||
// 无超时就是"任何插件泄漏一个孙进程都能拖死整个关停"。
|
||||
// 超时后主动关掉读端,强制 readLoop 从 Scan 里出来(file already closed,
|
||||
// 已在 waitLoop 里被列为预期错误)。
|
||||
readerDone := make(chan struct{})
|
||||
go func() {
|
||||
p.readerWG.Wait()
|
||||
close(readerDone)
|
||||
}()
|
||||
select {
|
||||
case <-readerDone:
|
||||
case <-time.After(killReapTimeout):
|
||||
log.Printf("[proc] %s 的 stdout 读取未在 %v 内结束(孙进程可能仍持有写端),强制关闭读端",
|
||||
p.name, killReapTimeout)
|
||||
if p.stdoutFile != nil {
|
||||
_ = p.stdoutFile.Close()
|
||||
}
|
||||
select {
|
||||
case <-readerDone:
|
||||
case <-time.After(killReapTimeout):
|
||||
// 极端情况:关管道也没能让它退出。不再等 —— 宁可让这次
|
||||
// Stop 少等 2 秒,也不能把关停无限期挂住。
|
||||
log.Printf("[proc] %s 读端关闭后 readLoop 仍未退出,放弃等待", p.name)
|
||||
}
|
||||
}
|
||||
if killErr != nil && !errors.Is(killErr, os.ErrProcessDone) {
|
||||
return fmt.Errorf("proc: 结束 %s: %w", p.name, killErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user