Files
HomeAgent/internal/plugin/dynamic_proc_unix.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

255 lines
8.5 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 || darwin
package plugin
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// tryLoadProc 只做静态校验(供双通道探测与测试),不构造插件实体。
//
// 真正加载走 Registry.loadProc子进程插件需要共享段 Host
// 而 Host 必须是**全部 .bin 插件共用的那一个**,只能由 Registry 持有。
//
// 返回 nil,nil 表示目录中没有 plugin.bin交由后续探测通道
// 找到二进制但不可用时返回明确错误——不静默回退到 cabi。
func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
_, err := validateProcBinary(dir, name)
return nil, err
}
// 子进程插件加载plugin.bin——外部插件多进程化的加载入口。
//
// 设计依据docs/zh/架构迁移评估.md §3stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面)
// 实施计划docs/zh/plugin-migration-plan.md Part 2/3
// validateProcBinary 校验 plugin.bin 是否存在且可执行。
// 返回 ("", nil) 表示该目录不是 proc 插件。
func validateProcBinary(dir, name string) (string, error) {
binPath := filepath.Join(dir, binEntry)
st, err := os.Stat(binPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("proc plugin %s: 检查 %s: %w", name, binEntry, err)
}
if st.IsDir() {
return "", fmt.Errorf("proc plugin %s: %s 是目录,不是可执行文件", name, binEntry)
}
if st.Mode()&0o111 == 0 {
// 常见于经 zip/hmap 分发丢失权限位——给出可直接执行的修复指令
return "", fmt.Errorf("proc plugin %s: %s 缺少可执行权限chmod +x %s",
name, binEntry, binPath)
}
return binPath, nil
}
// loadProc 构造子进程插件实体(不 spawn
//
// 共享段 Host 在此惰性创建:**全部 .bin 插件共用一块段**。
// 若每插件一段,多插件同阶段并发时会退化成副本模型,
// lost update 原样复现§8.4 实测 35.8~36.8%)。
func (r *Registry) loadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
binPath, err := validateProcBinary(dir, name)
if err != nil {
return nil, err
}
if binPath == "" {
return nil, nil
}
host, err := r.ensureProcHost()
if err != nil {
return nil, fmt.Errorf("proc plugin %s: %w", name, err)
}
// manifest 声明的能力集§3.8 权限梯度)。
// 无 manifest 或未声明 capabilities 时不限制,保存存量插件行为。
var caps []string
if mft := readManifest(dir); mft != nil {
caps = mft.Capabilities
if len(caps) > 0 {
log.Printf("[plugin] %s 声明能力: %v", name, caps)
}
}
return procPluginAdapter{
Plugin: proc.New(name, binPath, dir, config, host, r.onProcCrash, caps...),
}, nil
}
// ensureProcHost 惰性创建共享段 Host全进程唯一
func (r *Registry) ensureProcHost() (*proc.Host, error) {
r.procHostMu.Lock()
defer r.procHostMu.Unlock()
if r.procHost != nil {
return r.procHost, nil
}
host, err := proc.NewHost()
if err != nil {
return nil, err
}
r.procHost = host
// 事件环适配层Bus 发布 → 写 EvtRing slot → eventfd 通知子进程
if r.evBus != nil {
er := NewEventRing(host.EvtRing(), int(host.Evtfd().Fd()), r.evBus)
host.SetEvtSubscriber(er)
log.Printf("[plugin] 事件环已创建Bus → EvtRing → eventfd")
}
log.Printf("[plugin] 共享段已创建(全部子进程插件共用一块,%d KB", host.ShmSize()/1024)
return host, nil
}
// closeProcHost 释放共享段(仅在内核关停时调用)。
func (r *Registry) closeProcHost() {
r.procHostMu.Lock()
defer r.procHostMu.Unlock()
if r.procHost == nil {
return
}
if err := r.procHost.Close(); err != nil {
log.Printf("[plugin] 关闭共享段: %v", err)
}
r.procHost = nil
}
// onProcCrash 在子进程插件异常退出时回调。
//
// **崩溃隔离**子进程死亡只影响自己homed 继续服务——对比 C ABI 下
// 插件 panic 直接带崩整个进程§1.2,现网已发生)。
//
// 但「homed 没崩」不等于「内核状态干净」。此前本函数只发了一个事件,
// 而全仓没有任何订阅者,于是生产上出现过 editdoc 被 kill 后:
// - `edit_document` 仍留在 StageHost 的工具表里,模型照旧看得到、照旧调用,
// 每次都吃到 `proc: 插件进程已退出`
// - 该插件的 stage handler 仍在每轮 RunStage 里被并发调起并失败;
// - 没有任何路径把它拉回来,插件永久缺席直到重启 homed。
//
// 所以崩溃回调必须做三件事:摘注册面、喂健康计数、排一次重启。
func (r *Registry) onProcCrash(name string, err error) {
log.Printf("[plugin] 子进程插件 %s 异常退出: %vhomed 未受影响)", name, err)
// 1) 摘掉工具/stage/通道。**必须先做**:从这一刻起模型就不该再看到这些工具,
// 否则在重启完成前的窗口里每次调用都是确定的失败。
r.detachPlugin(name)
// 2) 从注册表移除。不做的后果scheduleProcRestart 里的“已被其他路径重新加载”
// 复核会误判旧条目还在See plugins[name] != nil跳过真正的自动重启。
// Plugin 对象本身仍被 proc 持有Kill/回收不受影响。
r.mu.Lock()
delete(r.plugins, name)
delete(r.sdkRefs, name)
for i, inst := range r.instances {
if inst.Name() == name {
r.instances = append(r.instances[:i], r.instances[i+1:]...)
break
}
}
r.mu.Unlock()
// 2) 事件通知webui/诊断插件可订阅)。
if r.evBus != nil {
r.evBus.Publish(&events.Event{
Type: events.EventSystem,
Source: "plugin",
Payload: map[string]interface{}{
"event": "plugin_crashed",
"plugin": name,
"error": err.Error(),
},
Timestamp: time.Now().Unix(),
})
}
// 3) 排一次重启。**必须异步**:本回调由 proc.markExited 在 readLoop 的
// goroutine 里触发,而 ReloadOne 要拿 registry 锁、还要 Kill 并 join 同一个
// readLoopProcess.Kill 里 readerWG.Wait同步调用会自锁死。
go r.scheduleProcRestart(name, err)
}
// scheduleProcRestart 在崩溃后按退避重启子进程插件。
//
// 退避与阈值语义与 agent 侧 plugin_health 对齐(窗口内 3 次即判定不健康),
// 但重启动作落在 registry崩溃事实产生于此agent 的 distillLoop 默认 30 分钟
// 才转一次(生产实配 2d靠它兜底等于插件缺席数小时。
func (r *Registry) scheduleProcRestart(name string, cause error) {
if r.shuttingDown.Load() {
return // 内核正在关停,不再拉起
}
if !r.AutoRestartEnabled(name) {
log.Printf("[plugin] %s 声明了不自动重启,保持缺席状态", name)
return
}
n := r.noteCrash(name)
if n > procMaxRestarts {
log.Printf("[plugin] %s 在 %v 内崩溃 %d 次,停止自动重启(需人工介入)",
name, procCrashWindow, n)
return
}
// 线性退避1 次→1s2 次→2s3 次→3s。崩溃循环时不至于打满 CPU
// 又足够快到用户感知不到工具缺席。
delay := time.Duration(n) * procRestartBackoff
time.Sleep(delay)
// 期间可能已被 Disable/Remove/手工 plgreload 处理掉,重启前复核。
if r.shuttingDown.Load() {
return
}
if r.isDisabled(name) {
log.Printf("[plugin] %s 已被禁用,取消自动重启", name)
return
}
r.mu.RLock()
already := r.plugins[name] != nil
r.mu.RUnlock()
if already {
log.Printf("[plugin] %s 已被其他路径重新加载,取消自动重启", name)
return
}
log.Printf("[plugin] 自动重启 %s第 %d 次,退避 %v起因: %v", name, n, delay, cause)
if err := r.ReloadOne(name); err != nil {
log.Printf("[plugin] %s 自动重启失败: %v", name, err)
return
}
log.Printf("[plugin] %s 自动重启成功", name)
}
// noteCrash 记录一次崩溃并返回窗口内的累计次数。
func (r *Registry) noteCrash(name string) int {
now := time.Now()
r.crashMu.Lock()
defer r.crashMu.Unlock()
if r.procCrashes == nil {
r.procCrashes = make(map[string]*procCrashRecord)
}
rec := r.procCrashes[name]
if rec == nil || now.Sub(rec.last) > procCrashWindow {
rec = &procCrashRecord{}
r.procCrashes[name] = rec
}
rec.count++
rec.last = now
return rec.count
}
// ResetProcCrashCount 清空某插件的崩溃计数(人工 plgreload / 重新启用后调用)。
func (r *Registry) ResetProcCrashCount(name string) {
r.crashMu.Lock()
defer r.crashMu.Unlock()
delete(r.procCrashes, name)
}