diff --git a/internal/plugin/dynamic_proc_unix.go b/internal/plugin/dynamic_proc_unix.go index 4787c3e..bbd229a 100644 --- a/internal/plugin/dynamic_proc_unix.go +++ b/internal/plugin/dynamic_proc_unix.go @@ -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 §3(stdio 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 +} + +// 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 } - return nil, fmt.Errorf("proc plugin %s: 子进程通道尚未实现(Part 2)——"+ - "共享内存数据面已就绪(internal/plugin/proc),"+ - "如需运行请把 plugin.json 的 entry 改回 %s 走 C ABI 通道", name, soEntry) + 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 异常退出: %v(homed 未受影响)", 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(), + }) } diff --git a/internal/plugin/proc/e2e_template_test.go b/internal/plugin/proc/e2e_template_test.go new file mode 100644 index 0000000..aba143b --- /dev/null +++ b/internal/plugin/proc/e2e_template_test.go @@ -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 —— 外部插件作者 +// 真正会拿到的那份运行时。它验证的是「模板 ↔ 内核」两侧协议/布局真的对齐, +// 而不只是内核自己跟自己对齐。 +// +// 插件业务代码只用公开 SDK(NewPluginFactory + 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.3:10 → 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 改写未回传:实际 %q(C 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) + } +} diff --git a/internal/plugin/proc/host.go b/internal/plugin/proc/host.go index d76feb5..1f9e7db 100644 --- a/internal/plugin/proc/host.go +++ b/internal/plugin/proc/host.go @@ -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_*.go):Linux 用 memfd,macOS 用 +// 立即 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 } diff --git a/internal/plugin/proc/shmalloc_darwin.go b/internal/plugin/proc/shmalloc_darwin.go new file mode 100644 index 0000000..37b5759 --- /dev/null +++ b/internal/plugin/proc/shmalloc_darwin.go @@ -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 +} diff --git a/internal/plugin/proc/shmalloc_linux.go b/internal/plugin/proc/shmalloc_linux.go new file mode 100644 index 0000000..636f297 --- /dev/null +++ b/internal/plugin/proc/shmalloc_linux.go @@ -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 +} diff --git a/internal/plugin/proc/shmalloc_other.go b/internal/plugin/proc/shmalloc_other.go new file mode 100644 index 0000000..06175e1 --- /dev/null +++ b/internal/plugin/proc/shmalloc_other.go @@ -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 +} diff --git a/internal/plugin/proc_core.go b/internal/plugin/proc_core.go new file mode 100644 index 0000000..443d136 --- /dev/null +++ b/internal/plugin/proc_core.go @@ -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{} diff --git a/internal/plugin/proc_load_test.go b/internal/plugin/proc_load_test.go new file mode 100644 index 0000000..a812eb8 --- /dev/null +++ b/internal/plugin/proc_load_test.go @@ -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) + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 53ce5a9..8755032 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -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 }