plugin: 子进程通道接通 registry(proc 通道端到端可运行)

Part 3 收尾。tryLoadProc 从桩位变成真实加载路径,plugin.bin 插件现在
经 registry 完整跑起来:spawn → 握手(共享段 fd 3)→ init/start →
反向注册 → 工具调用 → stage 共享内存读改写。

registry 侧:
- Registry 持有 procHost(惰性创建,**全部 .bin 插件共用一块段**)。
  每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成
  副本模型,lost update 原样复现(§8.4 实测 35.8~36.8%)。
- tryDynamic 分派到 Registry.loadProc;tryLoadProc 退为纯静态校验
  (构造需要 Host,只有 Registry 有)。
- StopAll 在锁外释放共享段:插件还持有映射时拆段,它们下一次访问就是
  SIGBUS;且持锁调用会与 onProcCrash 回调产生锁序风险。
- onProcCrash 把子进程退出转成 EventSystem 事件,不在回调里直接重载
  (重载需 registry 锁,而回调可能来自持锁路径的 goroutine)。

proc_core.go —— 权限梯度的类型系统落点(§3.8):
- procCore 用**命名字段**持有 *isdk.PluginSDK,不是嵌入。嵌入会提升全部
  方法,外部插件就能经类型断言拿到 Supervisor/Tracker/Adapter/Indexer/
  Status/Selftest。命名字段下只有显式写出的方法存在——权限梯度从
  「C ABI 表达能力的意外产物」变成显式声明并强制的策略。
- 能力访问器把内部超集接口收窄到公开面(isdk.KnowledgeAPI 内嵌
  pubsdk.KnowledgeAPI 再加 Stats/Remove,isdk.MemoryAPI 加 GraphData,
  isdk.LLMAPI 加 Chat/ReloadFromConfig);nil 保护避免类型化 nil 让
  corehandler 的判空失效。
- procPluginAdapter 转接 Start(*isdk.PluginSDK) → Start(proc.CoreSDK),
  Close 对 closeDynamic 可见故重载能真 kill 子进程(对比 dlclose 对
  Go c-shared 是 no-op,§1.1)。

共享段分配按平台拆分(原先 host.go 直接调 unix.MemfdCreate,darwin/windows
交叉编译失败):Linux memfd;macOS 立即 unlink 的临时文件(无 memfd_create,
但语义一致:无残留、fd 可经 ExtraFiles 传递、子进程 mmap 同一 inode);
其余平台明确报错而非静默降级成「无共享段」——那会让 stage 静默失去数据面。

测试 +13 项:
- e2e_template_test.go 用**真实 plugindev 模板**(而非 testdata 手写假插件)
  编译插件跑全链路,验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己
  跟自己对齐。含 lifecycle.autoRestart 上报、工具调用、stage 读改写、
  FinalText 回传(C ABI 下 after_toolcall 看不到此字段,§8.3 10→16)、
  只读插件不覆盖改写插件。
- proc_load_test.go 验证 Host 唯一性/惰性、chmod +x 错误提示、
  Close 可见性,以及 procCore 不暴露内核内部机制的断言。

验证:go build ./... 通过;go test -race ./internal/plugin/... 全绿;
全仓 go test 无新增失败;git diff third_party/homeagent-sdk/sdk/ 为空。
既有告警 cabi/loader.go:156 unsafe.Pointer 非本次引入。

Ref: docs/zh/架构迁移评估.md §3.3/§3.4/§3.8、docs/zh/plugin-migration-plan.md Part 3
This commit is contained in:
JianFeeeee
2026-09-02 13:03:57 +08:00
parent 82dcc86173
commit 11c1bbcebb
9 changed files with 881 additions and 50 deletions

View File

@ -4,40 +4,128 @@ 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"
)
// tryLoadProc 加载子进程插件plugin.bin——外部插件多进程化的加载入口
// 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——外部插件多进程化的加载入口。
//
// 设计依据docs/zh/架构迁移评估.md §3stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面)
// 实施计划docs/zh/plugin-migration-plan.md Part 2
//
// 当前状态:**分派桩位**。共享内存数据面与锁仲裁已在 internal/plugin/proc/ 落地
// 并通过 16 项测试(含 -race进程管理与 RPC 编解码为 Part 2 内容
//
// 返回 nil,nil 表示目录中没有 plugin.bin交由后续探测通道
// 找到二进制但通道未就绪时返回明确错误——不静默回退到 cabi
// 否则"已迁移插件跑回旧通道"极难排查。
func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, error) {
// 实施计划docs/zh/plugin-migration-plan.md Part 2/3
// 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, nil
return "", nil
}
return nil, fmt.Errorf("proc plugin %s: 检查 %s: %w", name, binEntry, err)
return "", fmt.Errorf("proc plugin %s: 检查 %s: %w", name, binEntry, err)
}
if st.IsDir() {
return nil, fmt.Errorf("proc plugin %s: %s 是目录,不是可执行文件", name, binEntry)
return "", fmt.Errorf("proc plugin %s: %s 是目录,不是可执行文件", name, binEntry)
}
if st.Mode()&0o111 == 0 {
return nil, fmt.Errorf("proc plugin %s: %s 缺少可执行权限chmod +x", name, binEntry)
// 常见于经 zip/hmap 分发丢失权限位——给出可直接执行的修复指令
return "", fmt.Errorf("proc plugin %s: %s 缺少可执行权限chmod +x %s",
name, binEntry, binPath)
}
return binPath, nil
}
return nil, fmt.Errorf("proc plugin %s: 子进程通道尚未实现Part 2——"+
"共享内存数据面已就绪internal/plugin/proc"+
"如需运行请把 plugin.json 的 entry 改回 %s 走 C ABI 通道", name, soEntry)
// 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)
}
return procPluginAdapter{Plugin: proc.New(name, binPath, dir, config, host, r.onProcCrash)}, 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
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,现网已发生)。
//
// 崩溃计数/冷却/自愈复用既有 plugin_health§2.3),本函数只负责把
// 进程退出这一事实转成事件通知;具体重载策略由 agent 侧决定。
func (r *Registry) onProcCrash(name string, err error) {
log.Printf("[plugin] 子进程插件 %s 异常退出: %vhomed 未受影响)", name, err)
if r.evBus == nil {
return
}
// 不在此处直接重载:重载需要 registry 锁,而本回调可能在
// 持锁路径的 goroutine 中触发,直接调用会死锁。
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(),
})
}

View File

@ -0,0 +1,259 @@
package proc
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 端到端:用**真实 plugindev 模板**编译的插件,经内核 proc 通道加载运行。
//
// 与 plugin_test.go 中 testdata/*.go 假插件的区别:
// 那些是手写的最简 RPC 实现,只验证内核侧逻辑;
// 这里用的是 tools/plugindev/templates/proc_main.go.tmpl —— 外部插件作者
// 真正会拿到的那份运行时。它验证的是「模板 ↔ 内核」两侧协议/布局真的对齐,
// 而不只是内核自己跟自己对齐。
//
// 插件业务代码只用公开 SDKNewPluginFactory + sdk.PluginSDK与 .so 时代一致。
const e2ePluginSource = `package main
import (
"strings"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type e2ePlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &e2ePlugin{name: name}, nil
}
func (p *e2ePlugin) Name() string { return p.name }
func (p *e2ePlugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
s.RegisterTool("e2e_echo", sdk.ToolDef{
Description: "回显",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"text": map[string]interface{}{"type": "string"},
},
},
}, func(args map[string]interface{}) (interface{}, error) {
t, _ := args["text"].(string)
return "echo:" + t, nil
})
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
for i := range ctx.ToolResults {
if str, ok := ctx.ToolResults[i].Result.(string); ok {
ctx.ToolResults[i].Result = strings.ReplaceAll(str, "脏", "净")
}
}
// FinalText 在 C ABI 下对 after_toolcall 不可见§8.310 → 16 字段)
ctx.FinalText = ctx.FinalText + "|stage-touched"
return nil
})
return nil
}
func (p *e2ePlugin) Stop() error { return nil }
`
// buildPluginWithRealTemplate 用 plugindev 的真实模板编译一个插件二进制。
func buildPluginWithRealTemplate(t *testing.T, businessCode string) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("环境无 go 工具链,跳过端到端测试")
}
tmpl := filepath.Join("..", "..", "..",
"third_party", "homeagent-sdk", "tools", "plugindev",
"templates", "proc_main.go.tmpl")
runtime, err := os.ReadFile(tmpl)
if err != nil {
t.Skipf("plugindev 模板不可读SDK 仓可能未就位): %v", err)
}
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "plugin.go"), businessCode)
mustWriteFile(t, filepath.Join(dir, "z_proc_gen.go"), string(runtime))
sdkPath, err := filepath.Abs(filepath.Join("..", "..", "..", "third_party", "homeagent-sdk"))
if err != nil {
t.Fatalf("解析 SDK 路径: %v", err)
}
mustWriteFile(t, filepath.Join(dir, "go.mod"),
"module e2eplugin\n\ngo 1.25\n\n"+
"require gitcode.com/JianFeeeee/homeagent-sdk v0.9.2\n\n"+
"replace gitcode.com/JianFeeeee/homeagent-sdk => "+sdkPath+"\n")
bin := filepath.Join(dir, "plugin.bin")
cmd := exec.Command("go", "build", "-o", bin, ".")
cmd.Dir = dir
// CGO_ENABLED=0模板零 cgo 是迁移的核心收益,这里同时充当回归保护
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("用真实模板编译插件失败: %v\n%s", err, out)
}
return bin
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("写 %s: %v", path, err)
}
}
// 完整链路:真实模板编译 → spawn → 握手 → init/start → 反向注册 → 工具调用 → stage 读改写。
func TestE2E_RealTemplatePluginFullLifecycle(t *testing.T) {
bin := buildPluginWithRealTemplate(t, e2ePluginSource)
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
p := New("e2e", bin, t.TempDir(), nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
// 1) SetAutoRestart 必须经 lifecycle.autoRestart 上报到内核。
// 公开 SDK 的 SetAutoRestart 是纯 setter无 hook插件在 Start() 里
// 调它只改自己进程内的副本;模板须在 Start 返回后显式上报一次。
if !core.autoStart {
t.Error("插件的 SetAutoRestart(true) 未传达到内核(模板漏了 lifecycle.autoRestart 上报?)")
}
// 2) 工具注册与调用
core.mu.Lock()
toolHandler, hasTool := core.tools["e2e_echo"]
core.mu.Unlock()
if !hasTool {
t.Fatal("插件注册的工具未到达内核")
}
res, err := toolHandler(map[string]interface{}{"text": "你好"})
if err != nil {
t.Fatalf("调用插件工具: %v", err)
}
if got, _ := res.(string); got != "echo:你好" {
t.Errorf("工具返回 %q期望 echo:你好", got)
}
// 3) stage 读改写经共享段回到内核 StageContext
handlers := core.stageHandlers(pubsdk.StageAfterToolcall)
if len(handlers) != 1 {
t.Fatalf("应注册 1 个 after_toolcall handler实际 %d", len(handlers))
}
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
FinalText: "原文",
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "t", Result: "这是脏数据"}},
}
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
t.Fatalf("stage 执行失败: %v", errs)
}
got, _ := sc.ToolResults[0].Result.(string)
if got != "这是净数据" {
t.Errorf("清洗结果未回到内核 StageContext实际 %q", got)
}
// FinalText 在 C ABI 的 after_toolcall 下根本看不到(只下发 10 字段中的一部分)
if sc.FinalText != "原文|stage-touched" {
t.Errorf("FinalText 改写未回传:实际 %qC ABI 下此字段在本阶段不可见)", sc.FinalText)
}
}
// 只读插件与改写插件并发时,改写结果不被覆盖。
//
// 这是本次迁移最关键的性质,用**真实模板**再验一次:
// 字段级脏写入使只读插件的写入集为空,物理上不可能覆盖他人改写。
// 对照 C ABI 副本模型实测 35.8~36.8% lost update§8.4)。
func TestE2E_RealTemplateReadOnlyPluginDoesNotOverwrite(t *testing.T) {
const readerSource = `package main
import sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
type readerPlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &readerPlugin{name: name}, nil
}
func (p *readerPlugin) Name() string { return p.name }
func (p *readerPlugin) Start(s *sdk.PluginSDK) error {
// 只读:遍历但不改任何字段
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
for range ctx.ToolResults {
}
_ = ctx.FinalText
return nil
})
return nil
}
func (p *readerPlugin) Stop() error { return nil }
`
writerBin := buildPluginWithRealTemplate(t, e2ePluginSource)
readerBin := buildPluginWithRealTemplate(t, readerSource)
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
// 两个插件共享同一 Host= 同一 memfd
// 若每插件一块段,这里就会退化成副本模型,本测试必然失败。
writer := New("writer", writerBin, t.TempDir(), nil, host, nil)
if err := writer.Start(core); err != nil {
t.Fatalf("writer.Start: %v", err)
}
defer writer.Close()
reader := New("reader", readerBin, t.TempDir(), nil, host, nil)
if err := reader.Start(core); err != nil {
t.Fatalf("reader.Start: %v", err)
}
defer reader.Close()
handlers := core.stageHandlers(pubsdk.StageAfterToolcall)
if len(handlers) != 2 {
t.Fatalf("应有 2 个 after_toolcall handler实际 %d", len(handlers))
}
sc := &pubsdk.StageContext{
Phase: pubsdk.StageAfterToolcall,
FinalText: "原文",
ToolResults: []pubsdk.ToolResult{{CallID: "c1", Name: "t", Result: "这是脏数据"}},
}
if errs := runStageLikeKernel(handlers, sc); len(errs) > 0 {
t.Fatalf("stage 执行失败: %v", errs)
}
got, _ := sc.ToolResults[0].Result.(string)
if got != "这是净数据" {
t.Fatalf("只读插件覆盖了改写插件的结果lost update实际 %q", got)
}
if !strings.Contains(sc.FinalText, "stage-touched") {
t.Errorf("FinalText 改写被覆盖:实际 %q", sc.FinalText)
}
}

View File

@ -7,7 +7,6 @@ import (
"sync"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"golang.org/x/sys/unix"
)
// Host 持有**被全部子进程插件共享的一块 StageContext 段**,是共享内存数据面的
@ -43,35 +42,26 @@ type Host struct {
coord *stageCoordinator
}
// NewHost 创建共享段(memfd + mmap + 布局初始化)。
// NewHost 创建共享段(平台层 allocShm + 布局初始化)。
//
// 用 memfd 而非 /dev/shm 文件:无需文件名、不残留(进程退出即回收)、
// 可经 ExtraFiles 传给子进程。实验 2 已验证父子 mmap 到不同虚拟地址时
// 相对偏移仍正确解引用。
// 段的分配按平台分开shmalloc_*.goLinux 用 memfdmacOS 用
// 立即 unlink 的临时文件(无 memfd_create其余平台明确报错。
// 两者语义一致无文件名残留fd 可经 ExtraFiles 传给子进程,
// 子进程 mmap 同一 inode——「全部插件共享一块段」的前提得以成立。
// 实验 2 已验证父子 mmap 到不同虚拟地址时相对偏移仍正确解引用。
func NewHost() (*Host, error) {
fd, err := unix.MemfdCreate("hastagectx", unix.MFD_CLOEXEC)
memfd, data, err := allocShm(shmDefaultSize)
if err != nil {
return nil, fmt.Errorf("proc: 创建共享段 memfd: %w", err)
}
if err := unix.Ftruncate(fd, int64(shmDefaultSize)); err != nil {
unix.Close(fd)
return nil, fmt.Errorf("proc: 共享段 ftruncate: %w", err)
}
data, err := unix.Mmap(fd, 0, shmDefaultSize,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
unix.Close(fd)
return nil, fmt.Errorf("proc: 共享段 mmap: %w", err)
return nil, err
}
seg, err := NewSegment(data)
if err != nil {
unix.Munmap(data)
unix.Close(fd)
freeShm(memfd, data)
return nil, err
}
return &Host{
memfd: os.NewFile(uintptr(fd), "hastagectx"),
memfd: memfd,
data: data,
seg: seg,
shmSize: shmDefaultSize,
@ -88,16 +78,9 @@ const shmDefaultSize = 256 * 1024
// Close 释放共享段。
func (h *Host) Close() error {
if h.data != nil {
unix.Munmap(h.data)
h.data = nil
}
if h.memfd != nil {
err := h.memfd.Close()
h.memfd = nil
return err
}
return nil
data, f := h.data, h.memfd
h.data, h.memfd = nil, nil
return freeShm(f, data)
}
// beginStage 由插件 handler 进入时调用。
@ -213,3 +196,6 @@ func (c *stageCoordinator) leave() (last bool, err error) {
}
return last, nil
}
// ShmSize 返回共享段大小(供诊断/日志)。
func (h *Host) ShmSize() int { return h.shmSize }

View File

@ -0,0 +1,50 @@
//go:build darwin
package proc
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
// allocShm 用临时文件 + mmap 创建共享段macOS
//
// macOS 没有 memfd_create。改用 os.CreateTemp 后立即 unlink文件名从目录树消失
// 但 fd 与映射继续有效,进程退出即回收——与 memfd 的不残留语义一致。
// 已 unlink 的 fd 仍可经 ExtraFiles 传给子进程,子进程 mmap 同一 inode
// 故「全部插件共享一块段」的前提在 macOS 同样成立。
func allocShm(size int) (*os.File, []byte, error) {
f, err := os.CreateTemp("", "hastagectx-*")
if err != nil {
return nil, nil, fmt.Errorf("proc: 创建共享段临时文件: %w", err)
}
// 立即摘除目录项:后续无人能按路径打开它,也不会有残留文件
if err := os.Remove(f.Name()); err != nil {
f.Close()
return nil, nil, fmt.Errorf("proc: unlink 共享段临时文件: %w", err)
}
if err := f.Truncate(int64(size)); err != nil {
f.Close()
return nil, nil, fmt.Errorf("proc: 共享段 ftruncate: %w", err)
}
data, err := unix.Mmap(int(f.Fd()), 0, size,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
f.Close()
return nil, nil, fmt.Errorf("proc: 共享段 mmap: %w", err)
}
return f, data, nil
}
// freeShm 解除映射并关闭段。
func freeShm(f *os.File, data []byte) error {
if data != nil {
unix.Munmap(data)
}
if f != nil {
return f.Close()
}
return nil
}

View File

@ -0,0 +1,43 @@
//go:build linux
package proc
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
// allocShm 用 memfd 创建共享段Linux
//
// 选 memfd 而非 /dev/shm 文件:无需文件名、不残留(最后一个 fd 关闭即回收)、
// 可经 ExtraFiles 传给子进程。实验 2 已验证父子 mmap 到不同虚拟地址时
// 相对偏移仍正确解引用——这是段内一律用偏移而非指针的前提。
func allocShm(size int) (*os.File, []byte, error) {
fd, err := unix.MemfdCreate("hastagectx", unix.MFD_CLOEXEC)
if err != nil {
return nil, nil, fmt.Errorf("proc: 创建共享段 memfd: %w", err)
}
if err := unix.Ftruncate(fd, int64(size)); err != nil {
unix.Close(fd)
return nil, nil, fmt.Errorf("proc: 共享段 ftruncate: %w", err)
}
data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
unix.Close(fd)
return nil, nil, fmt.Errorf("proc: 共享段 mmap: %w", err)
}
return os.NewFile(uintptr(fd), "hastagectx"), data, nil
}
// freeShm 解除映射并关闭段。
func freeShm(f *os.File, data []byte) error {
if data != nil {
unix.Munmap(data)
}
if f != nil {
return f.Close()
}
return nil
}

View File

@ -0,0 +1,28 @@
//go:build !linux && !darwin
package proc
import (
"fmt"
"os"
)
// allocShm 在尚未适配的平台上明确报错。
//
// 不静默降级成「无共享段」:那会让 stage 静默失去数据面,
// 插件看起来加载成功但读不到 StageContext——比启动失败难查得多。
//
// Windows 适配路径CreateFileMapping + MapViewOfFile句柄经
// PROC_THREAD_ATTRIBUTE_HANDLE_LIST 或命名段传给子进程。
// §9.2 已记录 Windows DLL 路径当前能力严重退化(只下发 3 字段、无写回),
// 迁移到子进程后三套 ABI 收敛为单一 RPC 实现Windows 反而受益,但需测试机验证。
func allocShm(size int) (*os.File, []byte, error) {
return nil, nil, fmt.Errorf("proc: 当前平台尚未支持共享内存数据面(需 CreateFileMapping 适配§9.2")
}
func freeShm(f *os.File, data []byte) error {
if f != nil {
return f.Close()
}
return nil
}

View File

@ -0,0 +1,171 @@
package plugin
import (
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
isdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// procCore 把内核的 *internal/sdk.PluginSDK 收窄成子进程插件可见的能力面。
//
// ❗ **必须用命名字段,不能嵌入** `*isdk.PluginSDK`:嵌入会让全部方法被提升,
// 外部插件通道就能经类型断言拿到 Supervisor()/Tracker()/Adapter()/Indexer()
// 这些内核内部机制——权限梯度退化成纸面约定。命名字段下只有下面显式写出的
// 方法存在,这才是 §3.8 说的「从 C ABI 表达能力的意外产物变成显式声明并强制的策略」。
//
// 另一个必要性internal/sdk 的接口是公开 SDK 的**超集**isdk.KnowledgeAPI
// 内嵌 pubsdk.KnowledgeAPI 再加 Stats()/Remove()isdk.MemoryAPI 加 GraphData()
// isdk.LLMAPI 加 Chat()/ReloadFromConfig()Go 方法签名精确匹配下
// *isdk.PluginSDK 本就不满足 proc.CoreSDK。
//
// 内置插件走的仍是原路径(直接持 *isdk.PluginSDK拿到全量接口不受影响。
type procCore struct {
sdk *isdk.PluginSDK
}
// newProcCore 包装内核 SDK 供子进程插件使用。
func newProcCore(s *isdk.PluginSDK) procCore { return procCore{sdk: s} }
func (c procCore) PluginName() string { return c.sdk.PluginName() }
// ---- 能力访问器:内部超集接口 → 公开 SDK 接口 ----
//
// nil 保护是必要的corehandler 用 `if xxx == nil` 判断能力不可用并返回
// errUnavailable若把「类型化的 nil」透过去判空会失效——插件收到的是
// panic 而不是"能力不可用"。
func (c procCore) Settings() pubsdk.SettingsAPI {
if s := c.sdk.Settings(); s != nil {
return s
}
return nil
}
func (c procCore) Memory() pubsdk.MemoryAPI {
if m := c.sdk.Memory(); m != nil {
return m
}
return nil
}
func (c procCore) TextMemory() pubsdk.TextMemoryAPI {
if m := c.sdk.TextMemory(); m != nil {
return m
}
return nil
}
func (c procCore) DocMemory() pubsdk.DocMemoryAPI {
if m := c.sdk.DocMemory(); m != nil {
return m
}
return nil
}
func (c procCore) Knowledge() pubsdk.KnowledgeAPI {
if k := c.sdk.Knowledge(); k != nil {
return k
}
return nil
}
func (c procCore) LLM() pubsdk.LLMAPI {
if l := c.sdk.LLM(); l != nil {
return l
}
return nil
}
func (c procCore) Social() pubsdk.SocialAPI {
if s := c.sdk.Social(); s != nil {
return s
}
return nil
}
func (c procCore) PluginMgr() pubsdk.PluginMgrAPI {
if m := c.sdk.PluginMgr(); m != nil {
return m
}
return nil
}
// ---- 注册面 ----
func (c procCore) RegisterTool(name string, def pubsdk.ToolDef, handler pubsdk.ToolHandler) error {
return c.sdk.RegisterTool(name, def, handler)
}
func (c procCore) RegisterStage(stage pubsdk.Stage, handler pubsdk.StageHandler, scope ...pubsdk.StageScope) {
c.sdk.RegisterStage(stage, handler, scope...)
}
func (c procCore) RegisterPluginAPI(name string) error {
return c.sdk.RegisterPluginAPI(name)
}
func (c procCore) RegisterOutputChannel(name string, caps int, desc string, def pubsdk.ChannelDef, handler pubsdk.ToolHandler) error {
return c.sdk.RegisterOutputChannel(name, caps, desc, def, handler)
}
func (c procCore) RegisterInputChannel(name string, def pubsdk.ChannelDef) error {
return c.sdk.RegisterInputChannel(name, def)
}
// ---- IO 注入 ----
func (c procCore) InjectText(source, channel, text string) {
c.sdk.InjectText(source, channel, text)
}
func (c procCore) InjectInterruptText(source, channel, text string) {
c.sdk.InjectInterruptText(source, channel, text)
}
func (c procCore) InjectTextNoMemory(source, channel, text string) {
c.sdk.InjectTextNoMemory(source, channel, text)
}
// InjectInputSync 收窄为公开 SDK 的三参数文本形态。
//
// internal/sdk.PluginSDK 的同名方法是 (source, channel, eventType, payload)
// → *agentIO.OutputEvent暴露了内核 IO 事件结构;外部插件只该看到回复文本。
// 取值方式与 C ABI 路径一致internal/plugin/cabi/loader.go 的 case 47
func (c procCore) InjectInputSync(source, channel, text string) string {
out := c.sdk.InjectInputSync(source, channel, "text", map[string]interface{}{
"content": text,
})
if out == nil {
return ""
}
reply, _ := out.Payload["content"].(string)
return reply
}
// ---- 生命周期 ----
func (c procCore) SetAutoRestart(enabled bool) { c.sdk.SetAutoRestart(enabled) }
// 编译期确认收窄面正好满足子进程插件的能力契约。
var _ proc.CoreSDK = procCore{}
// procPluginAdapter 把 *proc.Plugin 适配到 registry 的 sdk.Plugin 接口。
//
// 两者只差 Start 的参数类型registry 传 *isdk.PluginSDK全量能力
// 而子进程插件只该拿到收窄后的 proc.CoreSDK。转接在此发生
// 权限收窄就成了**类型系统强制**的事而不是约定§3.8)。
//
// Name/Stop/Close 经嵌入指针提升Close 对 registry.closeDynamic 可见,
// 故重载时能真正 kill 子进程——对比 cabi 路径的 Close 只做 dlclose
// 而 dlclose 对 Go c-shared 是 no-op§1.1,热重载静默失效的根因)。
type procPluginAdapter struct {
*proc.Plugin
}
// Start 把内核全量 SDK 收窄成子进程可见的能力面后启动进程。
func (a procPluginAdapter) Start(s *isdk.PluginSDK) error {
return a.Plugin.Start(newProcCore(s))
}
// 编译期确认适配器满足 registry 的插件接口。
var _ isdk.Plugin = procPluginAdapter{}

View File

@ -0,0 +1,192 @@
//go:build linux || darwin
package plugin
import (
"os"
"path/filepath"
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
isdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 子进程插件经 Registry 加载的接线测试Part 3 收尾)。
//
// 这里验证的是"内核侧接线",不是共享段语义本身——后者由
// internal/plugin/proc 的 34 项测试(含 -race覆盖。
// 共享段 Host 必须惰性创建且**全局唯一**。
//
// 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下
// 退化成副本模型lost update 原样复现§8.4 实测 35.8~36.8%)。
func TestRegistry_ProcHostIsSharedAndLazy(t *testing.T) {
r := NewRegistry()
defer r.closeProcHost()
if r.procHost != nil {
t.Error("共享段应惰性创建,未加载 .bin 插件时不该存在")
}
h1, err := r.ensureProcHost()
if err != nil {
t.Fatalf("创建共享段: %v", err)
}
h2, err := r.ensureProcHost()
if err != nil {
t.Fatalf("二次获取共享段: %v", err)
}
if h1 != h2 {
t.Fatal("共享段必须全局唯一——每插件一段会退化成副本模型lost update 复现")
}
if h1.ShmSize() <= 0 {
t.Errorf("共享段大小应为正数,实际 %d", h1.ShmSize())
}
}
func TestRegistry_CloseProcHostIsIdempotent(t *testing.T) {
r := NewRegistry()
if _, err := r.ensureProcHost(); err != nil {
t.Fatalf("创建共享段: %v", err)
}
r.closeProcHost()
r.closeProcHost() // 二次关闭不应 panic
if r.procHost != nil {
t.Error("关闭后 procHost 应为 nil")
}
}
// loadProc 对非 proc 目录返回 nil,nil交由后续探测通道
func TestRegistry_LoadProcSkipsNonProcDir(t *testing.T) {
r := NewRegistry()
defer r.closeProcHost()
plg, err := r.loadProc(t.TempDir(), "demo", nil)
if plg != nil || err != nil {
t.Fatalf("无 plugin.bin 应返回 nil,nil实际 plg=%v err=%v", plg, err)
}
if r.procHost != nil {
t.Error("非 proc 目录不该触发共享段创建")
}
}
// 缺可执行权限时报明确错误——常见于经 zip/hmap 分发丢失权限位。
func TestRegistry_LoadProcRejectsNonExecutable(t *testing.T) {
r := NewRegistry()
defer r.closeProcHost()
dir := t.TempDir()
path := filepath.Join(dir, binEntry)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
_, err := r.loadProc(dir, "demo", nil)
if err == nil {
t.Fatal("缺少可执行权限应报错")
}
// 错误消息须给出可直接执行的修复指令
if !strings.Contains(err.Error(), "chmod +x") {
t.Errorf("错误消息应含 chmod +x 修复指令,实际: %v", err)
}
}
// 构造出的插件必须能满足 registry 的 sdk.Plugin 接口(含 Close 供重载 kill 进程)。
func TestRegistry_LoadProcReturnsAdapter(t *testing.T) {
r := NewRegistry()
defer r.closeProcHost()
dir := t.TempDir()
path := filepath.Join(dir, binEntry)
if err := os.WriteFile(path, []byte("#!/bin/sh\nexec cat\n"), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
plg, err := r.loadProc(dir, "demo", nil)
if err != nil {
t.Fatalf("loadProc: %v", err)
}
if plg == nil {
t.Fatal("应返回插件实体")
}
if plg.Name() != "demo" {
t.Errorf("Name() = %q, want demo", plg.Name())
}
// Close 必须可见registry.closeDynamic 靠它真正 kill 子进程。
// 对比 cabi 路径的 Close 只做 dlclose而 dlclose 对 Go c-shared 是 no-op
// §1.1,热重载静默失效的根因)。
if _, ok := plg.(interface{ Close() error }); !ok {
t.Error("proc 插件须暴露 Close(),否则重载时子进程不会被回收")
}
if r.procHost == nil {
t.Error("加载 .bin 插件应触发共享段创建")
}
}
// procCore 必须满足 proc.CoreSDK且**刻意不暴露**内核内部机制。
//
// 这是 §3.8 的核心权限梯度从「C ABI 表达能力的意外产物」
// 变成显式声明并强制的策略。
//
// 守住的不变量procCore 用**命名字段**持有内核 SDK。若日后有人改成
// 嵌入 *isdk.PluginSDK全部方法会被提升外部插件就能经类型断言拿到
// 这些内核能力——下面的断言会当场拦住。
func TestProcCore_SatisfiesCoreSDKAndWithholdsInternals(t *testing.T) {
var _ proc.CoreSDK = procCore{}
var c interface{} = procCore{}
if _, has := c.(interface{ Selftest() *isdk.VirtualInstance }); has {
t.Error("procCore 不应暴露 Selftest§3.8 权限梯度)")
}
if _, has := c.(interface{ Supervisor() isdk.SupervisorAPI }); has {
t.Error("procCore 不应暴露 Supervisor§3.8 权限梯度)")
}
if _, has := c.(interface{ Tracker() isdk.TrackerAPI }); has {
t.Error("procCore 不应暴露 Tracker§3.8 权限梯度)")
}
if _, has := c.(interface{ Adapter() isdk.AdapterAPI }); has {
t.Error("procCore 不应暴露 Adapter§3.8 权限梯度)")
}
if _, has := c.(interface{ Indexer() isdk.IndexerAPI }); has {
t.Error("procCore 不应暴露 Indexer§3.8 权限梯度)")
}
if _, has := c.(interface{ Status() isdk.StatusAPI }); has {
t.Error("procCore 不应暴露 Status§3.8 权限梯度)")
}
}
// nil SDK 下各能力访问器必须返回真 nil而非类型化 nil
//
// corehandler 用 `if xxx == nil` 判断能力不可用并返回 errUnavailable
// 类型化 nil 会让判空失效,插件收到的是 panic 而不是"能力不可用"。
func TestProcCore_NilCapabilitiesAreTrueNil(t *testing.T) {
c := newProcCore(&isdk.PluginSDK{})
if c.Settings() != nil {
t.Error("Settings() 应为真 nil")
}
if c.Memory() != nil {
t.Error("Memory() 应为真 nil")
}
if c.TextMemory() != nil {
t.Error("TextMemory() 应为真 nil")
}
if c.DocMemory() != nil {
t.Error("DocMemory() 应为真 nil")
}
if c.Knowledge() != nil {
t.Error("Knowledge() 应为真 nil")
}
if c.LLM() != nil {
t.Error("LLM() 应为真 nil")
}
if c.PluginMgr() != nil {
t.Error("PluginMgr() 应为真 nil")
}
// 无 IOManager 时同步注入返回空串,不 panic
if got := c.InjectInputSync("s", "c", "t"); got != "" {
t.Errorf("无 IOManager 时应返回空串,实际 %q", got)
}
}

View File

@ -21,6 +21,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
@ -101,9 +102,17 @@ type Registry struct {
knownDisabled map[string]bool
allowlist map[string]bool
// pluginHashes 记录各插件二进制(plugin.so/main.lua)的 SHA256
// pluginHashes 记录各插件二进制(plugin.so/plugin.bin/main.lua)的 SHA256
// 供增量重载(Reload)对比:仅重载有变更的插件,避免全量 StopAll+Load 导致重复加载。
pluginHashes map[string]string
// procHost 是**全部子进程插件共享的那一块** StageContext 段§3.3/§3.4)。
//
// 懒创建(首个 .bin 插件加载时),随内核存活。共享而非每插件一段是关键:
// 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型
// lost update 原样复现§8.4 实测 35.8~36.8%)。
procHostMu sync.Mutex
procHost *proc.Host
}
func NewRegistry() *Registry {
@ -466,7 +475,6 @@ func (r *Registry) runOnRemoveHandlers(name string) {
func (r *Registry) StopAll() {
r.mu.Lock()
defer r.mu.Unlock()
for _, p := range r.instances {
r.runStopHandlers(p.Name())
if err := p.Stop(); err != nil {
@ -477,6 +485,12 @@ func (r *Registry) StopAll() {
r.instances = nil
r.pluginAutoRestart = make(map[string]bool)
r.sdkRefs = make(map[string]*sdk.PluginSDK)
r.mu.Unlock()
// 共享段在全部子进程退出后再释放:插件还持有映射时拆段,
// 它们下一次访问就是 SIGBUS。在锁外调用Close 不需 registry 锁,
// 而持锁调它会与 onProcCrash 路径(子进程退出回调)产生锁序风险。
r.closeProcHost()
}
func (r *Registry) Reload(dir string) (string, error) {
@ -894,7 +908,7 @@ func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}
// 按 manifest entry 分派到对应加载通道(外部插件多进程化:.so/.dll 与 .bin 双通道共存)。
// 这使迁移可逐插件推进、随时回退——把 entry 改回 plugin.so 即回到旧通道。
if detectEntryKind(plgDir) == entryProc {
plg, err := tryLoadProc(plgDir, name, config)
plg, err := r.loadProc(plgDir, name, config)
if err != nil {
return nil, err
}