Files
HomeAgent/internal/plugin/proc/supervisor.go
JianFeeeee 02cc74ce11 fix(proc): 子进程崩溃自愈 + 集中台账 + 注册面摘除
根因:子进程插件被 kill 后,内核只发了一个无人订阅的事件,
工具/stage handler/IO 通道全留在注册表里指向死进程,
模型继续调用只吃 ErrProcessExited,没有任何路径把插件拉回来。

## 四层修复

### 1. 专职 waitLoop(进程收割)
- 每个子进程配一根 waitLoop goroutine,是 cmd.Wait() 的唯一调用点
- 不再依赖 stdout EOF 判定死亡(孙子进程继承 stdout 时 EOF 永不到来)
- 手工 os.Pipe 替代 cmd.StdinPipe/StdoutPipe,避免 waitLoop 与
  os/exec 的内部关闭竞争
- host.go: Host.Supervisor(),Host.Close() 先 StopAll 再拆段

### 2. 集中台账 Supervisor
- proc/supervisor.go: 插件 Spawn 握手成功即 track,进程退出即 untrack
- StopAll: 并发发 plugin.stop 走优雅路径,到期仍在的一律 Kill
- 关停后才完成握手的进程被立即结束,不会活过内核
- 消除「孤儿进程持共享段映射 → SIGBUS」的隐患

### 3. 注册面摘除(detachPlugin)
- 新增 StageHost.UnregisterPluginStages:摘除指定插件的全部 stage handler
- 新增 Registry.pluginChannels 台账:记录每个插件注册的 IO 通道
- 三条路径统一走 detachPlugin:Disable / ReloadOne / RemovePlugin
- StopAndUnload 漏了 IO 通道也一并补上

### 4. 自动重启
- onProcCrash 从「只发事件」改为「摘注册面 → 从注册表移除 → 异步排重启」
- scheduleProcRestart: 窗口 5 分钟内最多 3 次,线性退避 1s/2s/3s
- 超限停手留日志;重启前复核是否已被 Disable 或被其他路径加载
- 崩溃计数窗口过期自动归零

### 5. 主动停止 vs 崩溃的区分
- proc.Plugin 新增 stopping 标志:Stop()/Close() 里 Set(true)
- handleExit 读 stopping 标志,主动停止不上报 onCrash
- 防止重载/禁用/卸载被误判为崩溃触发多余重启

### 6. Linux Pdeathsig 兜底
- procattr_linux.go: SysProcAttr.Pdeathsig = SIGKILL
- 兜 homed 自身被 SIGKILL/OOM 时子进程变孤儿的场景
- macOS/Windows 无等价物,空实现

### 7. pluginmgr 升级
- PluginManager 接口新增 PluginRuntime / ListPluginRuntimes
- plugin_list 输出运行态:loaded / alive / pid / crash_count / channel
- 新增 plugin_status: 全量运行期快照 + dead/unhealthy 汇总
- 新增 plugin_restart: 无条件重启单个插件(plgreload 不动未改二进制的插件)

### 测试
- process_test.go: 3 例(grandchild stdout 感知 / Supervisor track-untrack /
  StopAll 无孤儿)
- crash_recovery_test.go: 8 例(detach 三项齐全 / 通道重注册 / 崩溃不阻塞 /
  退避阈值 / 窗口过期 / 关停中跳过 / PluginRuntime 通道识别)
- stages_plugin_test.go: 4 例(stage 按插件摘除 / 空 stage 清理 / 空名 no-op /
  工具+stage 双摘后可重新注册同名)
2026-09-03 12:37:58 +08:00

164 lines
4.8 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 (
"fmt"
"log"
"sort"
"sync"
"time"
)
// Supervisor 是内核侧**唯一**的子进程台账。
//
// 为什么必须有它,而不是让每个 Plugin 各自管好自己的 Process
//
// 1. **没有台账就没有"全部子进程"这个概念**。内核关停时只能遍历 registry 的
// 插件表逐个 Stop而 registry 表是按插件名索引的——握手失败、Start 中途
// 出错、或刚 spawn 还没进表就崩了的进程registry 根本不知道它们存在,
// 那些进程会变成孤儿ppid=1继续跑还持有共享段映射。
// 2. **诊断面缺失**。此前 `/api/manager/status` 之类的接口拿不到"实跑几个子进程、
// 各自 PID 多少、活了多久、崩过几次",运维只能 ps | grep。
// 3. **收割保证**。每个 Process 自带一根 waitLoop 立即 wait4(2)Supervisor
// 只负责登记/注销与聚合视图;两者配合才能做到"进程一死内核立刻知道"。
//
// 生命周期Spawn 成功握手后 trackProcess.markExited 里 untrack。
type Supervisor struct {
mu sync.RWMutex
procs map[string]*Process
// closed 后拒绝新的 track防止关停竞态里又冒出新进程。
closed bool
}
// NewSupervisor 创建空台账。
func NewSupervisor() *Supervisor {
return &Supervisor{procs: make(map[string]*Process)}
}
// track 登记一个已握手成功的子进程。
//
// 同名覆盖是正常情况(重载:旧进程 untrack 早于或晚于新进程 track 都可能,
// 取决于 Kill 与 Spawn 的交错),故不报错,只在真覆盖时留日志。
func (s *Supervisor) track(p *Process) {
if p == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
// 关停途中还有进程完成握手:立即结束它,不让它活过内核。
go p.Kill()
return
}
if old, ok := s.procs[p.name]; ok && old != p {
log.Printf("[proc] 台账中 %s 已有 pid=%d被 pid=%d 覆盖", p.name, old.PID(), p.PID())
}
s.procs[p.name] = p
}
// untrack 注销(进程已退出)。只有当表里那一项确实是它时才删,
// 避免重载时新进程被旧进程的退出回调误删。
func (s *Supervisor) untrack(name string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.procs, name)
}
// Get 按插件名取子进程句柄。
func (s *Supervisor) Get(name string) (*Process, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.procs[name]
return p, ok
}
// Count 返回在册子进程数。
func (s *Supervisor) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.procs)
}
// ProcInfo 是单个子进程的运行期快照。
type ProcInfo struct {
Name string `json:"name"`
PID int `json:"pid"`
Alive bool `json:"alive"`
Bin string `json:"bin"`
}
// List 返回全部在册子进程的快照(按插件名排序,便于稳定展示)。
func (s *Supervisor) List() []ProcInfo {
s.mu.RLock()
out := make([]ProcInfo, 0, len(s.procs))
for name, p := range s.procs {
alive := true
select {
case <-p.Exited():
alive = false
default:
}
out = append(out, ProcInfo{Name: name, PID: p.PID(), Alive: alive, Bin: p.bin})
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// StopAll 停止全部在册子进程:先并发发 plugin.stop 走优雅路径,
// 到期仍在的一律 Kill。
//
// 这是内核关停时**必须**调的:不调则子进程被 init 收养成孤儿,
// 继续持有共享段映射(段已被内核 unmap它们下次访问就是 SIGBUS
// 并且下次 homed 启动时同名插件会与残留进程抢同一份外部资源
// qq 的 WS 连接、browser 的 chromium profile 锁)。
func (s *Supervisor) StopAll(timeout time.Duration) {
s.mu.Lock()
s.closed = true
procs := make([]*Process, 0, len(s.procs))
for _, p := range s.procs {
procs = append(procs, p)
}
s.mu.Unlock()
if len(procs) == 0 {
return
}
log.Printf("[proc] 关停 %d 个子进程插件", len(procs))
var wg sync.WaitGroup
for _, p := range procs {
wg.Add(1)
go func(pr *Process) {
defer wg.Done()
if err := pr.Stop(); err != nil {
log.Printf("[proc] 停止 %s: %v", pr.Name(), err)
}
}(p)
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
if timeout <= 0 {
timeout = stopGracePeriod * 2
}
select {
case <-done:
case <-time.After(timeout):
// 优雅停止没在预算内完成:剩下的直接 Kill。
// 不能无限等——homed 关停被单个卡住的插件拖住比杀掉它更糟。
var stuck []string
for _, p := range procs {
select {
case <-p.Exited():
default:
stuck = append(stuck, fmt.Sprintf("%s(pid=%d)", p.Name(), p.PID()))
go p.Kill()
}
}
if len(stuck) > 0 {
log.Printf("[proc] %v 内未优雅退出,强制结束: %v", timeout, stuck)
}
}
}