Files
HomeAgent/internal/plugin/dynamic_proc.go
JianFeeeee c0274b71d5 docs: 删除迁移期临时文档,现行内容搬进正式文档
用户指出迁移评估那批是**过程性临时文档**,迁移已完成就该退场。

## 删除(38 个文件)
- docs/zh/架构迁移评估.md(1621 行)—— 评估稿。开头的「现网正在发生的问题」
  (output_send 永远成功 / cgo 超时泄漏 26 次 / stage 污染)**全部已修复**,
  留着是误导性告警。其 §三「目标架构」已被 ARCHITECTURE.md 完整覆盖
  (且后者更细,含子进程生命周期管理)。
- docs/zh/plugin-interface-matrix.md(428 行)—— 迁移基线矩阵。
- docs/zh/experiments/(36 文件)—— 18 项可行性实验,验证的是"该不该迁移",
  迁移早已完成;实测无任何构建/测试依赖它。

## 现行内容先搬走(不能随临时文档一起丢)
- plugin-interface-matrix §九「接口扩展规则」→ 搬进 docs/git-branching.md 新增 §八
  (只增不减/签名不改、新增必须"插件调用内核实现"方向、hmapdev 模板必须同步接线
  否则全体插件编译失败、"接口纯追加"≠"无需重编"、合回 main 的同步清单)。
- git-branching §六 原写「接口冻结是合回门禁」—— 冻结是**迁移期**约束,v1.1.x 起
  已到期,改为标注失效并指向 §八。

## 引用清理
8 处引用全部改指现行文档:plan.md ×3、两篇设计文档各 ×1、
4 处源码注释(proc/shm.go、proc/process.go、dynamic_proc.go、entry_dispatch_test.go、
proc/bench_test.go)。仅 third_party(SDK 独立仓)保留 1 处,不动。

## 验证
- `go build ./...` 通过;`go test ./internal/plugin/...` 两个包全绿
- 本项目文档**断链 0**(另 2 处断链在 oh_modules 第三方依赖内)
2026-09-19 19:22:48 +08:00

267 lines
9.4 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.

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"
)
// 本文件平台中立。
//
// 曾经拆成 dynamic_proc_unix.go + dynamic_proc_windows.goPart 1610e9d0
// 当时共享内存只有 POSIX mmap 实现,故 Windows 侧只放了个报「尚未实现」的桩。
// 但那个桩只定义了 tryLoadProc而平台中立的 registry.go 还在调 loadProc /
// closeProcHost —— **Windows 下整个 homed 从那时起就编译不过**
// plan.md §12.5 声称「交叉编译通过」,实际只验证了 proc 子包)。
//
// Part 6.2d027c96补齐了 Windows 共享内存CreateFileMappingW、事件通知
// CreateEventW与段传递命名对象经环境变量桩却没人回头删。
//
// 现在合回一个文件:本文件里没有任何平台专属调用,全部差异封装在 proc 包的
// shmalloc_* / evtfd_* / shmpass_* / procattr_* 里,那些文件各自带构建标签。
// 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——外部插件多进程化的加载入口。
//
// 三个通信面stdio JSON-RPC控制+ 共享内存段(数据)+ eventfd 通知环。
// 设计取舍见 assets/docs/zh/ARCHITECTURE.md「子进程插件的三个通信面」。
// 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)
}