Files
HomeAgent/internal/plugins/real_plugin_smoke_test.go
JianFeeeee 1d7f011e5d plugin: 17 插件全量重编 + 端到端冒烟验证(Part 6.3)
## 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
2026-09-02 20:20:25 +08:00

259 lines
8.1 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.

//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 改写 ToolResultsweather 只读(不注册 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
}