mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +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:
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user