mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
根因:子进程插件被 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 双摘后可重新注册同名)
164 lines
4.8 KiB
Go
164 lines
4.8 KiB
Go
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 成功握手后 track,Process.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)
|
||
}
|
||
}
|
||
}
|