mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +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:
378
internal/plugin/proc/grandchild_test.go
Normal file
378
internal/plugin/proc/grandchild_test.go
Normal file
@ -0,0 +1,378 @@
|
||||
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 超时的成因)")
|
||||
}
|
||||
}
|
||||
@ -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