mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
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:
@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -163,7 +164,7 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
||||
|
||||
s.RegisterTool("plugin_list", sdk.ToolDef{
|
||||
Name: "plugin_list",
|
||||
Description: "列出已安装的所有外部插件及其版本",
|
||||
Description: "列出已安装的所有外部插件及其版本。同时返回运行状态(loaded/alive/pid/崩溃次数),子进程插件死了在此体现为 alive=false。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
@ -172,6 +173,46 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
||||
return p.listPlugins()
|
||||
})
|
||||
|
||||
s.RegisterTool("plugin_status", sdk.ToolDef{
|
||||
Name: "plugin_status",
|
||||
Description: "查看插件运行状态:进程是否存活、PID、加载通道、最近崩溃次数、当前注册的工具。" +
|
||||
"不传 name 则返回全部插件概览。工具调不通时先用它确认插件是否还活着。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "插件名称;缺省返回全部",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
return p.pluginStatus(name)
|
||||
})
|
||||
|
||||
s.RegisterTool("plugin_restart", sdk.ToolDef{
|
||||
Name: "plugin_restart",
|
||||
Description: "重启单个插件(停止后重新加载,保留配置)。适用于:插件进程已死但自动重启被用尽" +
|
||||
"(plugin_status 的 crash_count 达上限),或换了 plugin.bin 需立即生效。不需重启 homed。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "插件名称",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return map[string]interface{}{"error": "name is required"}, nil
|
||||
}
|
||||
return p.restartPlugin(name)
|
||||
})
|
||||
|
||||
s.RegisterTool("plugin_remove", sdk.ToolDef{
|
||||
Name: "plugin_remove",
|
||||
Description: "卸载一个已安装的外部插件",
|
||||
@ -540,6 +581,18 @@ func (p *Plugin) listPlugins() (interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 运行期状态一次取齐,避免逐个插件回内核查。
|
||||
//
|
||||
// 为何要带运行期:只读 plugin.json 的旧实现无法区分「已安装」与「正在跑」。
|
||||
// 生产上 editdoc 子进程被 kill 后,plugin_list 依旧把它列为正常插件,
|
||||
// 模型与 WebUI 都看不出异常,只能在调工具时吃一个“进程已退出”。
|
||||
runtimes := map[string]sdk.PluginRuntimeInfo{}
|
||||
if mgr := p.pluginMgr(); mgr != nil {
|
||||
for _, rt := range mgr.ListPluginRuntimes() {
|
||||
runtimes[rt.Name] = rt
|
||||
}
|
||||
}
|
||||
|
||||
var plugins []map[string]interface{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
@ -549,14 +602,27 @@ func (p *Plugin) listPlugins() (interface{}, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
plugins = append(plugins, map[string]interface{}{
|
||||
item := map[string]interface{}{
|
||||
"name": m.Name,
|
||||
"version": m.Version,
|
||||
"description": m.Description,
|
||||
"author": m.Author,
|
||||
"entry": m.Entry,
|
||||
"deprecated": m.Deprecated,
|
||||
})
|
||||
}
|
||||
if rt, ok := runtimes[m.Name]; ok {
|
||||
item["loaded"] = rt.Loaded
|
||||
item["alive"] = rt.Alive
|
||||
item["disabled"] = rt.Disabled
|
||||
item["channel"] = rt.Channel
|
||||
if rt.PID > 0 {
|
||||
item["pid"] = rt.PID
|
||||
}
|
||||
if rt.CrashCount > 0 {
|
||||
item["crash_count"] = rt.CrashCount
|
||||
}
|
||||
}
|
||||
plugins = append(plugins, item)
|
||||
}
|
||||
if plugins == nil {
|
||||
plugins = []map[string]interface{}{}
|
||||
@ -564,6 +630,84 @@ func (p *Plugin) listPlugins() (interface{}, error) {
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// pluginMgr 取内核插件管理面(可能为 nil:单测/未注入)。
|
||||
func (p *Plugin) pluginMgr() sdk.PluginManager {
|
||||
if p.sdk == nil {
|
||||
return nil
|
||||
}
|
||||
return p.sdk.PluginMgr()
|
||||
}
|
||||
|
||||
// pluginStatus 返回插件运行期状态(进程存活/PID/崩溃计数/工具清单)。
|
||||
//
|
||||
// 这是子进程化后插件管理器必须补上的一块:以前插件与内核同进程,
|
||||
// “加载了”就等于“能用”;现在插件是独立进程,两者不再等价。
|
||||
func (p *Plugin) pluginStatus(name string) (interface{}, error) {
|
||||
mgr := p.pluginMgr()
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("内核插件管理面不可用")
|
||||
}
|
||||
if name != "" {
|
||||
info, ok := mgr.PluginRuntime(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
all := mgr.ListPluginRuntimes()
|
||||
// 汇总一行:让模型不用自己数就能看出“有东西挂了”。
|
||||
var loaded, dead int
|
||||
var unhealthy []string
|
||||
for _, rt := range all {
|
||||
if rt.Loaded {
|
||||
loaded++
|
||||
}
|
||||
if rt.Loaded && !rt.Alive {
|
||||
dead++
|
||||
unhealthy = append(unhealthy, rt.Name)
|
||||
continue
|
||||
}
|
||||
if rt.CrashCount > 0 {
|
||||
unhealthy = append(unhealthy, fmt.Sprintf("%s(崩溃%d次)", rt.Name, rt.CrashCount))
|
||||
}
|
||||
}
|
||||
sort.Strings(unhealthy)
|
||||
return map[string]interface{}{
|
||||
"total": len(all),
|
||||
"loaded": loaded,
|
||||
"dead": dead,
|
||||
"unhealthy": unhealthy,
|
||||
"plugins": all,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// restartPlugin 重启单个插件(保留配置)。
|
||||
//
|
||||
// 与 plgreload 的区别:后者按入口文件 hash 增量重载,二进制没改就不动;
|
||||
// 而进程被 kill 时二进制正是没改的,所以一定要有一个无条件重启的入口。
|
||||
func (p *Plugin) restartPlugin(name string) (interface{}, error) {
|
||||
mgr := p.pluginMgr()
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("内核插件管理面不可用")
|
||||
}
|
||||
if _, ok := mgr.PluginRuntime(name); !ok {
|
||||
return nil, fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
if mgr.IsPluginDisabled(name) {
|
||||
return nil, fmt.Errorf("plugin %s 已被禁用,请先启用再重启", name)
|
||||
}
|
||||
if err := mgr.ReloadOne(name); err != nil {
|
||||
return nil, fmt.Errorf("restart %s: %w", name, err)
|
||||
}
|
||||
info, _ := mgr.PluginRuntime(name)
|
||||
return map[string]interface{}{
|
||||
"status": "restarted",
|
||||
"name": name,
|
||||
"runtime": info,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) removePlugin(name string) (interface{}, error) {
|
||||
// 内置插件只能禁用不能卸载:目录下无产物,且从注册表删除会破坏内核依赖。
|
||||
if p.sdk != nil && p.sdk.PluginMgr() != nil && p.sdk.PluginMgr().IsBuiltinPlugin(name) {
|
||||
|
||||
Reference in New Issue
Block a user