Files
HomeAgent/internal/plugins/agentcli/pty_linux.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

160 lines
4.1 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 (
"fmt"
"os"
"os/exec"
"sync"
"syscall"
"unsafe"
)
// PTY ioctl constants for Linux
const (
TIOCGPTN = 0x80045430
TIOCSPTLCK = 0x40045431
TIOCSWINSZ = 0x5414
)
type winsize struct {
Row uint16
Col uint16
XPixel uint16
YPixel uint16
}
func ioctl(fd, cmd uintptr, ptr unsafe.Pointer) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, cmd, uintptr(ptr))
if errno != 0 {
return errno
}
return nil
}
func openPty() (master *os.File, slave *os.File, err error) {
mfd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
return nil, nil, fmt.Errorf("open /dev/ptmx: %w", err)
}
master = os.NewFile(uintptr(mfd), "/dev/ptmx")
var unlock int32
if err := ioctl(uintptr(mfd), TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil {
master.Close()
return nil, nil, fmt.Errorf("TIOCSPTLCK: %w", err)
}
var ptyno int32
if err := ioctl(uintptr(mfd), TIOCGPTN, unsafe.Pointer(&ptyno)); err != nil {
master.Close()
return nil, nil, fmt.Errorf("TIOCGPTN: %w", err)
}
slavePath := fmt.Sprintf("/dev/pts/%d", ptyno)
sfd, err := syscall.Open(slavePath, syscall.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
master.Close()
return nil, nil, fmt.Errorf("open slave %s: %w", slavePath, err)
}
slave = os.NewFile(uintptr(sfd), slavePath)
return master, slave, nil
}
func defaultShell() string { return "bash" }
// linuxPty 基于 Linux PTY 的终端后端。
type linuxPty struct {
master *os.File
slave *os.File
cmd *exec.Cmd
// waitOnce/waitErr 保证 cmd.Wait() 恰好被调一次(重复 Wait 会报错)。
// 为什么必须 WaitnewCommandPty 只 Start 不 Wait子进程退出后没人回收
// 内核里会积下一堆 <defunct> 僵尸(线上实测 homed 名下已有一个)。
waitOnce sync.Once
waitErr error
}
func (p *linuxPty) Read(buf []byte) (int, error) { return p.master.Read(buf) }
func (p *linuxPty) WriteString(s string) (int, error) { return p.master.WriteString(s) }
func (p *linuxPty) Resize(rows, cols uint16) error {
ws := winsize{Row: rows, Col: cols}
if err := ioctl(uintptr(p.master.Fd()), TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {
return fmt.Errorf("TIOCSWINSZ: %w", err)
}
return nil
}
// Running 在 Linux 上保持旧语义:进程退出通过 master EOF 由 readLoop/cleanup 感知,
// 因此这里恒返回 true行为与改造前一致。
func (p *linuxPty) Running() bool { return true }
func (p *linuxPty) Kill() error {
if p.cmd == nil || p.cmd.Process == nil {
return nil
}
// 杀整个进程组,而不是只杀直接子进程。
//
// newCommandPty 用了 Setsid所以 sh 是新 session/pgid 的领头进程,
// pgid == sh 的 pid命令真正的进程如 sleep、vim是它的子进程
// 同属这个 pgid。只 Kill(sh) 会留下孤儿 sleep 继续跑(实测:终端超时
// 后 `sleep 300` 仍在,只是被 init 收养)。给负 pid 发信号 = 杀全组。
pid := p.cmd.Process.Pid
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
// 进程组不可用(已消亡/无权限)时回落到单进程 Kill。
return p.cmd.Process.Kill()
}
return nil
}
// Wait 回收子进程,避免 <defunct> 僵尸堆积。
func (p *linuxPty) Wait() error {
p.waitOnce.Do(func() {
p.waitErr = p.cmd.Wait()
})
return p.waitErr
}
func (p *linuxPty) Close() error {
p.slave.Close()
return p.master.Close()
}
// newCommandPty 创建 PTY 并在其上启动子命令sh -c
func newCommandPty(command string, rows, cols uint16) (ptyTerm, *exec.Cmd, error) {
master, slave, err := openPty()
if err != nil {
return nil, nil, err
}
cmd := exec.Command("sh", "-c", command)
cmd.Stdin = slave
cmd.Stdout = slave
cmd.Stderr = slave
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
Ctty: 0,
}
if err := cmd.Start(); err != nil {
master.Close()
slave.Close()
return nil, nil, fmt.Errorf("start command: %w", err)
}
slave.Close()
pt := &linuxPty{master: master, cmd: cmd}
if err := pt.Resize(rows, cols); err != nil {
master.Close()
cmd.Process.Kill()
return nil, nil, fmt.Errorf("resize pty: %w", err)
}
return pt, cmd, nil
}