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 双摘后可重新注册同名)
This commit is contained in:
JianFeeeee
2026-09-03 12:37:58 +08:00
parent 0012191cd5
commit d8ba6ff174
18 changed files with 1649 additions and 74 deletions

View File

@ -11,6 +11,8 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
@ -59,11 +61,19 @@ func RegisterFactory(name string, factory NativeFactory) {
globalFactories.Store(name, factory)
}
// PluginToolCleaner 定义插件工具注销接口,由 StageHost 实现。
// PluginToolCleaner 定义插件注销接口,由 StageHost 实现。
type PluginToolCleaner interface {
UnregisterPluginTools(pluginName string)
}
// PluginStageCleaner 摘除插件注册的 stage handler,由 StageHost 实现。
//
// 与 PluginToolCleaner 分开是为了向后兼容:旧 toolCleaner 实现(测试替身)
// 只有 UnregisterPluginTools,经类型断言取 stage 能力,取不到则跳过。
type PluginStageCleaner interface {
UnregisterPluginStages(pluginName string) int
}
type Registry struct {
mu sync.RWMutex
plugins map[string]sdk.Plugin
@ -113,6 +123,48 @@ type Registry struct {
// lost update 原样复现(§8.4 实测 35.8~36.8%)。
procHostMu sync.Mutex
procHost *proc.Host
// pluginChannels 记录每个插件注册过哪些 IO 通道(输出 device + 输入通道)。
//
// 不记的后果:子进程插件崩溃后它的 output device 仍在 IOManager 里,
// 模型依旧看到 output_send__<ch> 并调用,只能拿到 ErrProcessExited;
// 重启时 RegisterDevice 又因同名已存在而报 already registered,
// 插件回来了但通道永久指向旧进程的死闭包。
channelsMu sync.Mutex
pluginChannels map[string]*pluginChannelSet
// procCrashes 记录子进程插件的崩溃频次,防止崩溃循环无休止重启。
//
// 与 agent 侧 plugin_health 并存而非重复:后者只能看到工具调用路径上的
// panic,进程级退出(signal: killed / OOM / 自身 exit)根本不经那里。
crashMu sync.Mutex
procCrashes map[string]*procCrashRecord
// shuttingDown 在 StopAll 起始置位,用于冻结自动重启。
shuttingDown atomic.Bool
}
// 子进程插件自动重启策略。
const (
// procMaxRestarts 是窗口内允许的自动重启次数上限。
// 超过则停手:再重启也只是重复同一个崩溃,得让人看日志。
procMaxRestarts = 3
// procCrashWindow 内无新崩溃则计数归零。
procCrashWindow = 5 * time.Minute
// procRestartBackoff 是线性退避步长(第 n 次重启前等 n × 此值)。
procRestartBackoff = time.Second
)
// procCrashRecord 是单插件的崩溃计数。
type procCrashRecord struct {
count int
last time.Time
}
// pluginChannelSet 是单个插件注册过的通道名集合。
type pluginChannelSet struct {
outputs map[string]bool
inputs map[string]bool
}
func NewRegistry() *Registry {
@ -123,6 +175,7 @@ func NewRegistry() *Registry {
sdkRefs: make(map[string]*sdk.PluginSDK),
knownDisabled: make(map[string]bool),
pluginHashes: make(map[string]string),
pluginChannels: make(map[string]*pluginChannelSet),
}
}
@ -219,6 +272,13 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
if regStage == nil {
regStage = func(stage sdk.Stage, handler sdk.StageHandler) {}
}
// stage handler 注册时带上归属插件名,使卸载/崩溃时能成组摘除。
// StageHost 实现了 RegisterStageFor;其他实现(测试替身)退回无归属注册。
if h, ok := r.stageRegistrarFor(); ok {
regStage = func(stage sdk.Stage, handler sdk.StageHandler) {
h(name, stage, handler)
}
}
regAPI := r.regAPI
if regAPI == nil {
regAPI = func(name string) error { return nil }
@ -228,20 +288,25 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
if r.iom == nil {
return nil
}
return r.iom.RegisterDevice(&channelDevice{
if err := r.iom.RegisterDevice(&channelDevice{
name: chName,
caps: agentIO.OutputCapability(caps),
desc: desc,
handler: handler,
chDef: agentIO.ChannelDef(def),
})
}); err != nil {
return err
}
r.noteChannel(name, chName, true)
return nil
}
regInput := func(name string, def sdk.ChannelDef) error {
regInput := func(chName string, def sdk.ChannelDef) error {
if r.iom == nil {
return nil
}
r.iom.RegisterInputChannel(name, agentIO.ChannelDef(def))
r.iom.RegisterInputChannel(chName, agentIO.ChannelDef(def))
r.noteChannel(name, chName, false)
return nil
}
@ -466,6 +531,92 @@ func (r *Registry) runStopHandlers(name string) {
}
}
// stageRegistrarFor 取带归属的 stage 注册入口。
//
// r.regStage 是 cmd/homed 注入的闭包(无插件名参数),而 stageHost 本体同时
// 以 sdk.ToolSource 存在 r.stageHost 上。能取到 RegisterStageFor 时就直接用它,
// 否则退回无归属注册(测试替身、旧集成方)。
func (r *Registry) stageRegistrarFor() (func(plugin string, stage sdk.Stage, handler sdk.StageHandler), bool) {
if r.stageHost == nil {
return nil, false
}
if h, ok := r.stageHost.(interface {
RegisterStageFor(plugin string, stage sdk.Stage, handler sdk.StageHandler)
}); ok {
return h.RegisterStageFor, true
}
return nil, false
}
// noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。
func (r *Registry) noteChannel(plugin, channel string, output bool) {
if plugin == "" || channel == "" {
return
}
r.channelsMu.Lock()
defer r.channelsMu.Unlock()
set := r.pluginChannels[plugin]
if set == nil {
set = &pluginChannelSet{outputs: map[string]bool{}, inputs: map[string]bool{}}
r.pluginChannels[plugin] = set
}
if output {
set.outputs[channel] = true
} else {
set.inputs[channel] = true
}
}
// releasePluginChannels 摘除插件注册过的全部 IO 通道,返回摘除的通道名。
//
// 必须做:不摘除则 ① 模型仍看得到 output_send__<ch> 却永远失败;
// ② 插件重启时 RegisterDevice 报 already registered,新进程的通道注不上,
// 通道永久指向已死进程的闭包。
func (r *Registry) releasePluginChannels(plugin string) []string {
if plugin == "" {
return nil
}
r.channelsMu.Lock()
set := r.pluginChannels[plugin]
delete(r.pluginChannels, plugin)
r.channelsMu.Unlock()
if set == nil || r.iom == nil {
return nil
}
var released []string
for ch := range set.outputs {
r.iom.UnregisterDevice(ch)
released = append(released, ch)
}
for ch := range set.inputs {
r.iom.UnregisterInputChannel(ch)
if !set.outputs[ch] {
released = append(released, ch)
}
}
sort.Strings(released)
return released
}
// detachPlugin 把插件在内核侧的全部注册面摸干净:工具 + stage handler + IO 通道。
//
// 这是「卸载一个插件」的完整含义。之前各路径(Disable/Reload/Remove/
// StopAndUnload)只调 UnregisterPluginTools,漏了 stage 与通道两项,
// 子进程崩溃路径更是三项都没做。
func (r *Registry) detachPlugin(name string) {
if r.toolCleaner != nil {
r.toolCleaner.UnregisterPluginTools(name)
if sc, ok := r.toolCleaner.(PluginStageCleaner); ok {
if n := sc.UnregisterPluginStages(name); n > 0 {
log.Printf("[plugin] %s: 摘除 %d 个 stage handler", name, n)
}
}
}
if chans := r.releasePluginChannels(name); len(chans) > 0 {
log.Printf("[plugin] %s: 摘除 IO 通道 %v", name, chans)
}
}
// runOnRemoveHandlers 执行插件注册的删除清理回调(SDK 层),插件 Stop() 之后、从注册表移除前执行。
func (r *Registry) runOnRemoveHandlers(name string) {
if sdk, ok := r.sdkRefs[name]; ok {
@ -474,6 +625,10 @@ func (r *Registry) runOnRemoveHandlers(name string) {
}
func (r *Registry) StopAll() {
// 关停开始即冻结自动重启:否则「Stop 触发退出 → 崩溃判定 → 重新 spawn」
// 会在内核正在关停时把子进程又拉起来,段已拆而进程还在,直接 SIGBUS。
r.shuttingDown.Store(true)
r.mu.Lock()
for _, p := range r.instances {
r.runStopHandlers(p.Name())
@ -567,6 +722,11 @@ func (r *Registry) ReloadOne(name string) error {
}
r.mu.Unlock()
// 重载前必须把旧注册面摸干净。不做的后果:loadOne 重新 Start 时
// RegisterTool 碰上同名旧工具直接报 already registered,新实例的工具一个都注不上;
// stage handler 与 output device 同理——旧闭包指向已死进程,永不退场。
// (此前只有 agent 的 autoReloadPlugins 在外层手动摸工具,plgreload 路径漏了。)
r.detachPlugin(name)
r.closeDynamic(removed)
ok := r.loadOne(plgDir, name)
@ -657,9 +817,7 @@ func (r *Registry) Disable(name string) error {
r.knownDisabled[name] = true
r.mu.Unlock()
if r.toolCleaner != nil {
r.toolCleaner.UnregisterPluginTools(name)
}
r.detachPlugin(name)
if r.cfgReg != nil {
r.cfgReg.AddDisabledPlugin(name, "system")
@ -767,9 +925,7 @@ func (r *Registry) DisablePlugin(name, by string) error {
r.knownDisabled[name] = true
r.mu.Unlock()
if r.toolCleaner != nil {
r.toolCleaner.UnregisterPluginTools(name)
}
r.detachPlugin(name)
if r.cfgReg != nil {
r.cfgReg.AddDisabledPlugin(name, by)
@ -804,9 +960,7 @@ func (r *Registry) StopAndUnload(name string) error {
}
r.mu.Unlock()
if r.toolCleaner != nil {
r.toolCleaner.UnregisterPluginTools(name)
}
r.detachPlugin(name)
r.closeDynamic(unloaded)
log.Printf("[plugin] unloaded (config kept): %s", name)
return nil
@ -837,9 +991,7 @@ func (r *Registry) RemovePlugin(name string) error {
r.runOnRemoveHandlers(name)
r.mu.Unlock()
if r.toolCleaner != nil {
r.toolCleaner.UnregisterPluginTools(name)
}
r.detachPlugin(name)
if r.cfgReg != nil {
r.cfgReg.RemoveDisabledPlugin(name)
r.cfgReg.RemovePlugin(name)
@ -851,6 +1003,98 @@ func (r *Registry) RemovePlugin(name string) error {
func (r *Registry) ReloadPlugins() (string, error) { return r.Reload(r.plgDir) }
// PluginRuntime 返回单个插件的运行期状态。
//
// 这是「插件管理器能看到真实死活」的数据源。子进程模型下,
// 「注册表里有条目」不等于「进程还活着」;只读 plugin.json 的旧实现
// 无法区分两者,插件被 kill 后 WebUI 仍显示“正常”。
func (r *Registry) PluginRuntime(name string) (sdk.PluginRuntimeInfo, bool) {
if name == "" {
return sdk.PluginRuntimeInfo{}, false
}
r.mu.RLock()
plg, loaded := r.plugins[name]
_, hasFactory := r.factories[name]
r.mu.RUnlock()
if !hasFactory {
_, hasFactory = globalFactories.Load(name)
}
installed := loaded || hasFactory
var dirExists bool
if r.plgDir != "" {
if st, err := os.Stat(filepath.Join(r.plgDir, name)); err == nil && st.IsDir() {
dirExists = true
installed = true
}
}
if !installed {
return sdk.PluginRuntimeInfo{}, false
}
info := sdk.PluginRuntimeInfo{
Name: name,
Loaded: loaded,
Disabled: r.isDisabled(name),
Builtin: hasFactory,
AutoRestart: r.AutoRestartEnabled(name),
CrashCount: r.crashCount(name),
}
switch {
case hasFactory:
info.Channel = "builtin"
case dirExists:
info.Channel = detectEntryKind(filepath.Join(r.plgDir, name)).String()
}
// 子进程插件报真实 PID 与存活;其余形态与 Loaded 同值(无独立进程)。
type procStatus interface {
PID() int
Alive() bool
}
if ps, ok := plg.(procStatus); ok && loaded {
info.PID = ps.PID()
info.Alive = ps.Alive()
} else {
info.Alive = loaded
}
if r.stageHost != nil {
for _, def := range r.stageHost.GetToolDefs() {
if def.Plugin == name {
info.Tools = append(info.Tools, def.Name)
}
}
sort.Strings(info.Tools)
}
return info, true
}
// ListPluginRuntimes 返回全部已知插件的运行期状态(含已安装未加载者)。
func (r *Registry) ListPluginRuntimes() []sdk.PluginRuntimeInfo {
names := r.ListKnown()
out := make([]sdk.PluginRuntimeInfo, 0, len(names))
for _, name := range names {
if info, ok := r.PluginRuntime(name); ok {
out = append(out, info)
}
}
return out
}
// crashCount 读取窗口内的崩溃计数(过期视为 0)。
func (r *Registry) crashCount(name string) int {
r.crashMu.Lock()
defer r.crashMu.Unlock()
rec := r.procCrashes[name]
if rec == nil || time.Since(rec.last) > procCrashWindow {
return 0
}
return rec.count
}
// ListKnown 返回所有已知插件(已加载 + 已禁用 + 已安装但未加载)。
func (r *Registry) ListKnown() []string {
r.mu.RLock()