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 双摘后可重新注册同名)
72 lines
2.1 KiB
Go
72 lines
2.1 KiB
Go
//go:build ignore
|
||
|
||
// forkplugin 在启动时 fork 一个存活时间比自己长的子进程(继承同一个 stdout),
|
||
// 然后在收到 die 工具调用时让自己退出。
|
||
//
|
||
// 用途:复现「EOF 不等于进程死亡」这一缺陷。
|
||
// 插件本体死后,孙子进程仍持有 stdout 写端,父进程(homed)的 readLoop
|
||
// 永远读不到 EOF——若内核只靠 EOF 判定死亡,就会完全感知不到插件已死:
|
||
// 工具调用一直超时、崩溃回调不触发、自动重启永不发生。
|
||
// 生产上 browser 拉 chromium、editdoc 拉 python 都是这个形状。
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"os"
|
||
"os/exec"
|
||
)
|
||
|
||
type request struct {
|
||
ID uint64 `json:"id,omitempty"`
|
||
Method string `json:"method"`
|
||
Params json.RawMessage `json:"params,omitempty"`
|
||
}
|
||
|
||
type response struct {
|
||
ID uint64 `json:"id"`
|
||
Result interface{} `json:"result,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
func main() {
|
||
// 孙子进程**只**继承 stdout(本测试的要点),不给 stderr:
|
||
// 插件的 stderr 直通到 go test 的捕获管道,孙子抿着它不放会让
|
||
// go test 在测试全部通过后仍等 60s I/O。
|
||
//
|
||
// sleep 给 3s:只需在插件本体退出的那一瞬间它还持有写端即可(实际 <200ms),
|
||
// 不必拖得更久而拖慢测试。
|
||
child := exec.Command("sleep", "3")
|
||
child.Stdout = os.Stdout
|
||
_ = child.Start()
|
||
|
||
in := bufio.NewScanner(bufio.NewReader(os.Stdin))
|
||
out := bufio.NewWriter(os.Stdout)
|
||
send := func(v interface{}) {
|
||
b, _ := json.Marshal(v)
|
||
out.Write(b)
|
||
out.WriteByte('\n')
|
||
out.Flush()
|
||
}
|
||
|
||
for in.Scan() {
|
||
var req request
|
||
if err := json.Unmarshal(in.Bytes(), &req); err != nil {
|
||
continue
|
||
}
|
||
switch req.Method {
|
||
case "handshake":
|
||
send(response{ID: req.ID, Result: map[string]interface{}{
|
||
"protocol": 1, "sdk_version": "test", "plugin_name": "fork", "pid": os.Getpid(),
|
||
}})
|
||
case "tool.invoke":
|
||
// 不回应答,直接退出:模拟插件突然死亡(崩溃/被 kill)。
|
||
os.Exit(7)
|
||
default:
|
||
if req.ID != 0 {
|
||
send(response{ID: req.ID})
|
||
}
|
||
}
|
||
}
|
||
}
|