Files
HomeAgent/internal/plugins/agentcli/plugin_lifecycle_test.go
JianFeeeee 9de3b365a6 fix(agentcli): 修 4 个真实缺陷——停机死锁、超时泄漏、僵尸堆积、孙进程逃逸
jianf 提示 agentcli 可能有问题,系统性审了一遍(含 -race 与线上实证),
确认并修复 4 个互相叠加的真实缺陷,每个都配了「去掉修复即失败」的回归测试。

1) 停机/热重载死锁(plugin_stop_test.go)
   Stop() 先 p.wg.Wait() 再 Close 终端,而 readLoop 自己也记在 p.wg 上、
   只监 t.stopCh 不监 p.stopCh。只要有一个终端开着,wg.Wait() 就永不返回。
   后果:插件卸载/热重载(StopAndUnload/ReloadOne)与停机全挂死,且
   registry 持锁时是整个内核一起挂。
   修:先关活跃终端(move 出 map 后在锁外 Close),再 wg.Wait();
   readLoop 顶部加 p.stopCh 探测;Stop() 用 sync.Once 保证幂等。

2) 终端超时后资源全泄漏(plugin_lifecycle_test.go)
   readLoop 的 IsExpired 分支只 delete(sessions) 后 return,既不 Kill 也不
   Close。终端已被移出 sessions,cleanupLoop 也再看不到它,进程/PTY fd/
   reader 协程无人回收。实测:timeout=1s 的 sleep 300 超时后进程仍在跑。
   修:readLoop 加 defer releaseResources(),保证「只要退出就释放」。

3) 子进程从不回收 → <defunct> 僵尸堆积(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 只 Start 从不 Wait。线上实测 homed 名下已有一个
   [sh] <defunct> 僵尸子进程。
   修:linuxPty 加 Wait()(sync.Once 保证只 Wait 一次),
   releaseResources 通过可选接口 Wait() error 调用(Windows ConPTY 不实现则跳过)。

4) Kill 只杀直接子进程,孙进程逃逸(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 用 Setsid,sh 是新进程组领头,真正的命令(sleep/vim)是
   其孙进程且同组。只 Kill(sh) 会留下孤儿继续跑。实测:`sleep 300; echo done`
   只杀 leader 后 sleep 仍在(被 init 收养)。
   修:改为 syscall.Kill(-pid, SIGKILL) 杀整个进程组,失败再回落单进程 Kill。

测试设计要点:回归用例必须让「sh 保留为父进程 + 孙进程显式 trap "" HUP」,
否则单个 sleep 会被 sh exec 掉、关 PTY 的 SIGHUP 又会顺手带走孙进程,
两个缺陷都测不出来(这两种情况都实际踩过并修正了用例)。

全量 go test ./internal/... ./cmd/... 通过,agentcli 单包 -race 通过。
2026-09-17 20:24:03 +08:00

121 lines
3.6 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.

//go:build linux
package agentcli
import (
"os"
"strconv"
"strings"
"syscall"
"testing"
"time"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 超时终端必须真正释放:杀掉整个进程组(包括 sh 的子进程如 sleep
// 且回收子进程(不留 <defunct>)。
//
// 旧实现三个缺陷叠加:
// 1. readLoop 的 IsExpired 分支只 delete(sessions) 后 return不 Kill 不 Close
// 2. Kill 只杀直接子进程 shSetsid 后真正的命令sleep是孙进程成为孤儿
// 3. 只 Start 从不 Wait退出的子进程无人回收积成僵尸。
func TestExpiredTerminalReleasesProcessGroup(t *testing.T) {
p := New("agentcli")
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
RegTool: func(string, sdk.ToolDef, sdk.ToolHandler) error { return nil },
RegStage: func(sdk.Stage, sdk.StageHandler) {},
RegAPI: func(string) error { return nil },
Settings: sdk.NewSettings("agentcli", nil),
})
sdkInst.SetIOInjector(&injectCapture{})
if err := p.Start(sdkInst); err != nil {
t.Fatal(err)
}
defer p.Stop()
// 命令设计要点(缺一不可):
// 1) 让 sh 保留为父进程、另起孙进程(`... & wait`)——单个 `sleep 300`
// 会被 sh 直接 exec 掉,只有一个进程,测不到「孙进程逃逸」;
// 2) 孙进程显式忽略 SIGHUP——否则关 PTY master 时内核发的 SIGHUP 会
// 顺手把它带走,于是「只杀 leader」也能通过测不出进程组 Kill 的必要性。
// 两个条件合起来,只有给整个进程组发 SIGKILL 才能清干净。
marker := `(trap "" HUP; sleep 300) & wait`
res, err := p.handleCreate(sdkInst, map[string]interface{}{
"command": marker,
"timeout": "1s",
})
if err != nil {
t.Fatal(err)
}
m := res.(map[string]interface{})
if m["error"] != nil {
t.Skipf("PTY unavailable: %v", m["error"])
}
p.mu.Lock()
ts := p.sessions[m["id"].(string)]
p.mu.Unlock()
if ts == nil {
t.Fatal("terminal not registered")
}
pid := ts.cmd.Process.Pid
// 等超时被 readLoop 处理(含 Kill 进程组 + Wait 回收)
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if !procAlive(pid) && !procGroupAlive(pid) {
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("expired terminal not released: leader pid=%d alive=%v groupAlive=%v",
pid, procAlive(pid), procGroupAlive(pid))
}
func procAlive(pid int) bool { return syscall.Kill(pid, 0) == nil }
// procGroupAlive 检查「原进程组」里是否还有活着的成员(含被 init 收养的孙进程)。
//
// 不能用 kill(-pgid, 0):组领头进程一死,内核就可能回收该 pgid
// 即便组里还有被 reparent 的成员,这个探测也会失败。
// 改为直接遍历 /proc 查 pgid 匹配的活进程。
func procGroupAlive(pgid int) bool {
entries, err := os.ReadDir("/proc")
if err != nil {
return false
}
for _, e := range entries {
if pid, err := strconv.Atoi(e.Name()); err == nil {
if readPgid(pid) == pgid {
return true
}
}
}
return false
}
// readPgid 从 /proc/<pid>/stat 读进程组 id第 5 个字段)。
// stat 的 comm 字段可能含空格/括号,所以从最后一个 ')' 之后再切分。
func readPgid(pid int) int {
data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return -1
}
s := string(data)
i := strings.LastIndex(s, ")")
if i < 0 {
return -1
}
fields := strings.Fields(s[i+1:])
if len(fields) < 3 {
return -1
}
// fields[0]=state, [1]=ppid, [2]=pgrp
pgid, err := strconv.Atoi(fields[2])
if err != nil {
return -1
}
return pgid
}