mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
## 17 个插件源码零改动,全部重编为 plugin.bin 16 个 × 3 平台(linux/darwin/windows),qq 1 平台(plg.json 自己声明 bundle:false)。luademo 走 Lua 解释器不适用。 git status example/ 无输出 —— 这是「业务代码零改动」的硬证据。 批 3 那些预估高风险的插件(qq 2686 行双向通道、browser 12 工具 + InjectInterruptText、a2a/acp 的 InjectInputSync 同步注入)一次全过, 因为它们只碰公开 SDK 合同面,而合同面在 Part 2 已 51 个 method 全量平移。 唯一一次失败与迁移无关:rss 的 github.com/mmcdole/gofeed 不在本地模块 缓存且 proxy.golang.org 不通,换 GOPROXY=https://goproxy.cn 后通过。 ## 真实 homed 加载验证 15 个外部插件全部经 proc 通道建链(protocol=1 sdk=0.9.2,各自独立 PID), 31 个插件 loaded(15 外部 + 16 内置)。 ai_image / files 未走 proc 通道:同名内置插件优先(工厂编译期注册), 外部插件被遮蔽。这是既有行为,与迁移无关。 事件环与共享段均正常创建,且共享段是**一块** 256KB 服务全部 15 个插件。 ## 冒烟测试 4 项(internal/plugins/real_plugin_smoke_test.go) 用真实 example 产物而非 testdata 假插件;manifest 刻意写 "entry":"plugin.so" 验证工具链与内核都已不看 entry 值。未重编时 skip 而非 fail。 - ToolInvokeRoundTrip:工具真实调用往返(此前只验证到"注册")。 weather_current 返回结构化参数校验错误——这恰是链路通的证据。 - StageRewriteTakesEffect:sanitizer 清洗 ANSI 序列,改写经共享段回到 内核 StageContext - MultiPluginShareOneSegment:sanitizer + weather 并发,清洗结果不被覆盖 - CrashDoesNotKillKernel:SIGKILL 插件进程后 homed 存活、17 插件仍在 (对比 C ABI 下插件 panic 直接带崩 homed,§1.2 现网已发生) ## 开销实测与基线偏差 15 个插件进程 RSS=88.0MB PSS=87.9MB 线程=82,均摊 5.87MB / 5.5 线程。 RSS 88MB vs 实验 5 基线 29.1MB **不是回归,是基线不可比**:实验 5 用 2.68MB 最小插件,真实插件 3.1~14.8MB。可比的结构性指标: - 均摊线程 5.5 vs 4.9 —— 同量级,无线程膨胀 - PSS/RSS 99.9% vs 44% —— **明显差于基线** 第二项是真实发现:基线里 PSS 远低于 RSS 说明 Go runtime 只读代码页在 进程间共享;实测几乎不共享,因为 15 个插件是 15 个不同的二进制,没有 共同物理页可映射。这是「每插件独立二进制」的固有代价,意味着实际内存 开销高于 §4.3 的乐观估计。压这一项的方向是共享 launcher 二进制。 ## 工具脚本入 experiments/19-migration-verify scripts/ 被 .gitignore 排除,故放到已跟踪的 experiments 目录下, 与 01~18 的可复跑实验并列。 measure-plugin-overhead.sh 第一版有统计口径 bug:RSS 读 status 的 VmRSS、 PSS 读 smaps_rollup 的 Pss,输出 PSS(87.9MB) > RSS(69.1MB) —— 物理上不可能。 两者对共享内存段计入方式不同(smaps 的 Rss 含 Pss_Shmem)。已统一从 smaps_rollup 读。另修 bc 不可用导致 MB 全显示 0.0(改用 awk)。 Ref: docs/zh/plugin-migration-plan.md Part 6.3、docs/zh/架构迁移评估.md §4.3
259 lines
8.1 KiB
Go
259 lines
8.1 KiB
Go
//go:build linux || darwin
|
||
|
||
package plugins
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"syscall"
|
||
"testing"
|
||
"time"
|
||
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// 真实外部插件(重编为 plugin.bin)经内核加载后的端到端冒烟(Part 6.3)。
|
||
//
|
||
// 与 internal/plugin/proc 的测试的区别:
|
||
// 那些用 testdata 假插件或临时编译的最小插件验证机制;
|
||
// 这里用 **example/ 里真实的 17 个插件产物**,验证「业务代码零改动 + 重编即可」
|
||
// 这一迁移承诺在完整内核装配下成立。
|
||
//
|
||
// 前置:插件需已用新版 plugindev 重编(scripts/rebuild-plugins.sh)。
|
||
// 未重编时测试 skip 而非 fail——CI 上不强制要求先跑重编脚本。
|
||
|
||
// realPluginDir 返回某个 example 插件的 linux 产物路径。
|
||
func realPluginBinary(t *testing.T, name string) string {
|
||
t.Helper()
|
||
root, err := filepath.Abs(filepath.Join("..", "..", "third_party", "homeagent-sdk", "example", name))
|
||
if err != nil {
|
||
t.Fatalf("解析插件目录: %v", err)
|
||
}
|
||
// bundle 模式产物带平台后缀,单平台模式不带
|
||
candidates := []string{
|
||
filepath.Join(root, "build", fmt.Sprintf("plugin.bin_%s_%s", runtime.GOOS, runtime.GOARCH)),
|
||
filepath.Join(root, "build", "plugin.bin"),
|
||
}
|
||
for _, c := range candidates {
|
||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||
return c
|
||
}
|
||
}
|
||
t.Skipf("插件 %s 未重编(先跑 scripts/rebuild-plugins.sh)", name)
|
||
return ""
|
||
}
|
||
|
||
// installRealPlugin 把真实插件产物装进测试用 plugins 目录。
|
||
func installRealPlugin(t *testing.T, plgDir, name string) {
|
||
t.Helper()
|
||
src := realPluginBinary(t, name)
|
||
|
||
dst := filepath.Join(plgDir, name)
|
||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||
t.Fatalf("建插件目录: %v", err)
|
||
}
|
||
data, err := os.ReadFile(src)
|
||
if err != nil {
|
||
t.Fatalf("读产物 %s: %v", src, err)
|
||
}
|
||
binPath := filepath.Join(dst, "plugin.bin")
|
||
if err := os.WriteFile(binPath, data, 0o755); err != nil {
|
||
t.Fatalf("写产物: %v", err)
|
||
}
|
||
|
||
// manifest 刻意写 "plugin.so":验证工具链/内核都已不看 entry 值。
|
||
// 17 个存量插件的 plg.json 都是这个值,没人去改——这正是「零改动」的含义。
|
||
manifest := fmt.Sprintf(`{"name":%q,"name_zh":%q,"name_en":%q,"version":"1.0.0","entry":"plugin.so"}`,
|
||
name, name, name)
|
||
if err := os.WriteFile(filepath.Join(dst, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||
t.Fatalf("写 manifest: %v", err)
|
||
}
|
||
}
|
||
|
||
// 真实插件经内核加载 → 注册工具 → **实际调用工具**。
|
||
//
|
||
// 之前的测试只验证到"注册",这里验证调用往返:
|
||
// 内核 ExecuteTool → RPC → 插件进程 handler → 结果回传。
|
||
func TestRealPlugin_ToolInvokeRoundTrip(t *testing.T) {
|
||
env := setupIntegration(t)
|
||
defer env.cleanup()
|
||
|
||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||
installRealPlugin(t, plgDir, "weather")
|
||
|
||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||
t.Fatalf("加载插件: %v", err)
|
||
}
|
||
|
||
// 确认经 proc 通道加载(而非被同名内置插件遮蔽)
|
||
if env.pluginReg.Get("weather") == nil {
|
||
t.Fatal("weather 未加载")
|
||
}
|
||
|
||
// weather 注册的工具名带插件名前缀(内核 SetToolRegistrar 加的)
|
||
var toolName string
|
||
for _, def := range env.stageHost.GetToolDefs() {
|
||
if strings.Contains(def.Name, "weather") {
|
||
toolName = def.Name
|
||
break
|
||
}
|
||
}
|
||
if toolName == "" {
|
||
t.Fatal("weather 未注册任何工具")
|
||
}
|
||
t.Logf("调用工具 %s", toolName)
|
||
|
||
// 真实调用:weather 会发 HTTP 请求到 wttr.in,网络不通时返回错误而非 panic。
|
||
// 这里只断言"调用链路通"——RPC 往返成功、handler 被执行、结果或错误正常回传。
|
||
res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{"city": "Beijing"})
|
||
if err != nil {
|
||
// 网络错误是可接受的:链路通了才能拿到插件侧的错误
|
||
if strings.Contains(err.Error(), "not found in any plugin") {
|
||
t.Fatalf("工具未注册到 stageHost: %v", err)
|
||
}
|
||
t.Logf("工具返回错误(网络受限环境正常): %v", err)
|
||
return
|
||
}
|
||
if res == nil {
|
||
t.Error("工具返回 nil 结果且无错误")
|
||
}
|
||
t.Logf("工具返回: %.120v", res)
|
||
}
|
||
|
||
// sanitizer 的改写型 stage 在真实内核装配下生效。
|
||
//
|
||
// 这是迁移最核心的性质:C ABI 副本模型下多插件并发时实测 35.8~36.8%
|
||
// lost update(§8.4),共享内存 + 字段级脏写入后应为 0。
|
||
func TestRealPlugin_StageRewriteTakesEffect(t *testing.T) {
|
||
env := setupIntegration(t)
|
||
defer env.cleanup()
|
||
|
||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||
installRealPlugin(t, plgDir, "sanitizer")
|
||
|
||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||
t.Fatalf("加载插件: %v", err)
|
||
}
|
||
if env.pluginReg.Get("sanitizer") == nil {
|
||
t.Fatal("sanitizer 未加载")
|
||
}
|
||
|
||
// sanitizer 注册 after_toolcall 清洗 ANSI 转义序列
|
||
dirty := "结果:\x1b[31m告警文本\x1b[0m 结束"
|
||
sc := &pubsdk.StageContext{
|
||
Phase: pubsdk.StageAfterToolcall,
|
||
ToolResults: []pubsdk.ToolResult{
|
||
{CallID: "c1", Name: "some_tool", Result: dirty},
|
||
},
|
||
}
|
||
|
||
env.stageHost.RunStage(pubsdk.StageAfterToolcall, sc)
|
||
|
||
got, _ := sc.ToolResults[0].Result.(string)
|
||
if got == dirty {
|
||
t.Errorf("sanitizer 的清洗未生效(结果未变):%q", got)
|
||
}
|
||
if strings.Contains(got, "\x1b[") {
|
||
t.Errorf("ANSI 序列未被清除:%q", got)
|
||
}
|
||
t.Logf("清洗前: %q\n清洗后: %q", dirty, got)
|
||
}
|
||
|
||
// 多插件共享同一块共享段,只读插件不覆盖改写插件的结果。
|
||
//
|
||
// 若每插件一块段,「内核 ctx → 段 → 插件改 → 回读 ctx」会退化成副本模型,
|
||
// 最后回读者覆盖前者,lost update 原样复现。
|
||
func TestRealPlugin_MultiPluginShareOneSegment(t *testing.T) {
|
||
env := setupIntegration(t)
|
||
defer env.cleanup()
|
||
|
||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||
// sanitizer 改写 ToolResults,weather 只读(不注册 after_toolcall 的改写)
|
||
installRealPlugin(t, plgDir, "sanitizer")
|
||
installRealPlugin(t, plgDir, "weather")
|
||
|
||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||
t.Fatalf("加载插件: %v", err)
|
||
}
|
||
|
||
dirty := "输出:\x1b[33m黄色\x1b[0m"
|
||
sc := &pubsdk.StageContext{
|
||
Phase: pubsdk.StageAfterToolcall,
|
||
ToolResults: []pubsdk.ToolResult{
|
||
{CallID: "c1", Name: "t", Result: dirty},
|
||
},
|
||
}
|
||
|
||
env.stageHost.RunStage(pubsdk.StageAfterToolcall, sc)
|
||
|
||
got, _ := sc.ToolResults[0].Result.(string)
|
||
if strings.Contains(got, "\x1b[") {
|
||
t.Errorf("并发下清洗结果被覆盖(lost update):%q", got)
|
||
}
|
||
}
|
||
|
||
// 崩溃隔离:kill 掉插件子进程,homed(测试进程)必须存活。
|
||
//
|
||
// 对比 C ABI:插件 panic 直接带崩整个 homed 进程(§1.2,现网已发生)。
|
||
func TestRealPlugin_CrashDoesNotKillKernel(t *testing.T) {
|
||
env := setupIntegration(t)
|
||
defer env.cleanup()
|
||
|
||
plgDir := filepath.Join(env.tmpDir, "plugins")
|
||
installRealPlugin(t, plgDir, "editdoc")
|
||
|
||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||
t.Fatalf("加载插件: %v", err)
|
||
}
|
||
if env.pluginReg.Get("editdoc") == nil {
|
||
t.Fatal("editdoc 未加载")
|
||
}
|
||
|
||
// 找到插件子进程并 SIGKILL
|
||
pid := findPluginPID(t, "editdoc")
|
||
if pid == 0 {
|
||
t.Skip("未找到插件子进程(进程名匹配失败)")
|
||
}
|
||
t.Logf("kill 插件进程 pid=%d", pid)
|
||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||
t.Fatalf("kill: %v", err)
|
||
}
|
||
|
||
// 内核必须存活并能继续工作
|
||
time.Sleep(300 * time.Millisecond)
|
||
if env.pluginReg.List() == nil {
|
||
t.Fatal("内核在插件崩溃后不可用")
|
||
}
|
||
t.Logf("插件崩溃后内核存活,已加载插件数=%d", len(env.pluginReg.List()))
|
||
}
|
||
|
||
// findPluginPID 按二进制路径找插件子进程 pid。
|
||
func findPluginPID(t *testing.T, name string) int {
|
||
t.Helper()
|
||
out, err := exec.Command("pgrep", "-f", "plugin.bin").Output()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
for _, line := range strings.Fields(string(out)) {
|
||
pid := 0
|
||
fmt.Sscanf(line, "%d", &pid)
|
||
if pid == 0 {
|
||
continue
|
||
}
|
||
// 校验 cwd 或 cmdline 含插件名
|
||
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
|
||
if err == nil && strings.Contains(exe, name) {
|
||
return pid
|
||
}
|
||
cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
|
||
if err == nil && strings.Contains(cwd, name) {
|
||
return pid
|
||
}
|
||
}
|
||
return 0
|
||
}
|