diff --git a/docs/zh/experiments/plugin-arch/19-migration-verify/README.md b/docs/zh/experiments/plugin-arch/19-migration-verify/README.md new file mode 100644 index 0000000..9b69747 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/19-migration-verify/README.md @@ -0,0 +1,83 @@ +# 实验 19:迁移验证工具(Part 6.3) + +外部插件从 C ABI 动态库迁移到子进程后的批量重编与开销实测工具。 +与 01~18 的性质不同:那些是**决策前**的可行性验证,这两个是**迁移执行期** +反复使用的操作脚本。 + +## rebuild-plugins.sh + +批量把 `example/` 下的插件重编为子进程模式(`plugin.bin`)。 + +```bash +PLUGINDEV=/tmp/plugindev ./rebuild-plugins.sh weather sanitizer qq +``` + +关键性质:**不修改任何插件源码**。`plg.json` 的 `entry` 仍写着 `"plugin.so"` +也无妨——工具链已不看这个字段(Part 6.1)。 + +两个实现细节值得记: + +- **成功判定看产物而非退出码**。plugindev 对部分错误只 `fmt.Printf` 不 + `os.Exit`,单看 `$?` 会把失败当成功。 +- 构建前清 `build/`+`dist/`。残留的 `.so` 不影响构建,但会让人误以为 + 还在用旧通道。 + +已知环境依赖:`rss` 插件需要 `github.com/mmcdole/gofeed`, +`proxy.golang.org` 不通时用 `GOPROXY=https://goproxy.cn,direct`。 + +## measure-plugin-overhead.sh + +实测 homed + 插件子进程的常驻开销。 + +```bash +./measure-plugin-overhead.sh $(pgrep -f 'homed -data' | head -1) +``` + +### 一个统计口径的坑 + +第一版混用了两个来源:RSS 读 `/proc/pid/status` 的 `VmRSS`, +PSS 读 `smaps_rollup` 的 `Pss`。结果输出 `PSS=87.9MB > RSS=69.1MB`—— +物理上不可能。 + +原因是两者对**共享内存段**的计入方式不同:`smaps_rollup` 的 `Rss` 含 +`Pss_Shmem`(共享段的按比例份额),`VmRSS` 不含。现已统一从 +`smaps_rollup` 读,保证 PSS ≤ RSS。 + +### 实测结果(2026-09-02,15 个真实插件) + +``` +15 个插件进程 RSS=88.0 MB PSS=87.9 MB 线程=82 +均摊 5.87 MB 5.86 MB 5.5 线程 +homed 本体 RSS=182 MB 线程=15 +``` + +**与实验 5 基线(17 进程 RSS=29.1MB / PSS=12.9MB / 线程=84)的偏差解释**: + +实验 5 用的是 2.68MB 的最小插件,真实插件 3.1~14.8MB(browser 依赖最多)。 +RSS 随二进制体积线性增长,故绝对数字不可比。可比的是结构性指标: + +| 指标 | 基线 | 实测 | 判断 | +|---|---|---|---| +| 均摊线程 | 4.9 | 5.5 | 同量级,无线程膨胀 | +| PSS/RSS | 44% | 99.9% | **明显差于基线** | + +第二项是真实发现:基线里 PSS 远低于 RSS,说明 Go runtime 只读代码页在 +进程间共享。实测几乎不共享,因为 15 个插件是 15 个**不同**的二进制, +没有共同的物理页可映射。 + +这是「每插件独立二进制」的固有代价,不是缺陷,但意味着实际内存开销 +高于评估文档(§4.3)的乐观估计。若日后需要压这一项,方向是让插件共享 +一个 launcher 二进制 + 各自的业务 plugin,而非各自静态链接整个 runtime。 + +## 冒烟测试 + +自动化部分在 `internal/plugins/real_plugin_smoke_test.go`(4 项): + +- `ToolInvokeRoundTrip`:工具真实调用往返(不只是注册) +- `StageRewriteTakesEffect`:sanitizer 改写型 stage 在真实内核装配下生效 +- `MultiPluginShareOneSegment`:多插件共享一段,只读插件不覆盖改写结果 +- `CrashDoesNotKillKernel`:SIGKILL 插件进程,homed 存活 + +这些测试用**真实 example 产物**而非 testdata 假插件,且 manifest 刻意写 +`"entry":"plugin.so"`——验证「业务代码零改动」这一承诺在完整内核装配下成立。 +未重编时 skip 而非 fail,CI 不强制先跑重编脚本。 diff --git a/docs/zh/experiments/plugin-arch/19-migration-verify/measure-plugin-overhead.sh b/docs/zh/experiments/plugin-arch/19-migration-verify/measure-plugin-overhead.sh new file mode 100755 index 0000000..d9254f2 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/19-migration-verify/measure-plugin-overhead.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# 子进程插件常驻开销实测(Part 6.3 验收项)。 +# +# 对照基线:docs/zh/experiments/plugin-arch 实验 5 实测 17 子进程 +# PSS=12.9MB / RSS=29.1MB / 线程=84(原文档估计 50-70MB 偏高)。 +# +# 用法:./measure-plugin-overhead.sh +set -uo pipefail + +pid=${1:-} +if [ -z "$pid" ]; then + echo "用法: $0 " >&2 + exit 1 +fi +if [ ! -d "/proc/$pid" ]; then + echo "进程 $pid 不存在" >&2 + exit 1 +fi + +# homed 本体 +homed_rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status") +homed_thr=$(awk '/^Threads:/ {print $2}' "/proc/$pid/status") + +echo "=== homed 本体 ===" +printf "RSS=%s kB 线程=%s\n" "$homed_rss" "$homed_thr" + +# 插件子进程:homed 的直接子进程中执行 plugin.bin 的 +echo +echo "=== 插件子进程 ===" +total_rss=0 +total_pss=0 +total_thr=0 +count=0 + +for child in $(pgrep -P "$pid" 2>/dev/null); do + exe=$(readlink "/proc/$child/exe" 2>/dev/null || true) + case "$exe" in + *plugin.bin*) ;; + *) continue ;; + esac + + thr=$(awk '/^Threads:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0) + # RSS 与 PSS 统一从 smaps_rollup 读,保证口径一致。 + # 混用 status 的 VmRSS 与 smaps 的 Pss 会得出 PSS > RSS 的荒谬结果—— + # 两者对共享内存段(Pss_Shmem)的计入方式不同。 + rss=$(awk '/^Rss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0) + pss=$(awk '/^Pss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0) + if [ -z "$rss" ] || [ "$rss" = "0" ]; then + rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0) + fi + binsz=$(stat -c%s "$(readlink "/proc/$child/exe" 2>/dev/null)" 2>/dev/null || echo 0) + name=$(basename "$(readlink "/proc/$child/cwd" 2>/dev/null || echo unknown)") + + printf " %-16s pid=%-8s RSS=%-8s PSS=%-8s 线程=%-3s 二进制=%s MB\n" \ + "$name" "$child" "$rss" "$pss" "$thr" \ + "$(awk -v b="$binsz" 'BEGIN{printf "%.1f", b/1048576}')" + total_rss=$((total_rss + rss)) + total_pss=$((total_pss + pss)) + total_thr=$((total_thr + thr)) + count=$((count + 1)) +done + +echo +echo "=== 合计($count 个插件进程)===" +awk -v rss="$total_rss" -v pss="$total_pss" -v thr="$total_thr" -v n="$count" ' +BEGIN { + printf "RSS=%d kB (%.1f MB)\n", rss, rss/1024 + printf "PSS=%d kB (%.1f MB)\n", pss, pss/1024 + printf "线程=%d\n", thr + if (n > 0) printf "均摊 RSS=%.2f MB PSS=%.2f MB 线程=%.1f\n", rss/1024/n, pss/1024/n, thr/n +}' + +echo +echo "注:RSS/PSS 均取自 smaps_rollup,口径一致(PSS ≤ RSS)。" +echo "PSS 低于 RSS 的部分即 Go runtime 只读代码页在进程间的共享收益。" + +echo +echo "对照实验 5 基线:17 进程 RSS=29.1MB PSS=12.9MB 线程=84" +echo +echo "⚠️ 该基线用的是 2.68MB 的最小插件;真实插件 3.3~15.2MB(browser 依赖最多)。" +echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——" +echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。" diff --git a/docs/zh/experiments/plugin-arch/19-migration-verify/rebuild-plugins.sh b/docs/zh/experiments/plugin-arch/19-migration-verify/rebuild-plugins.sh new file mode 100755 index 0000000..ea37160 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/19-migration-verify/rebuild-plugins.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# 批量重编外部插件为子进程模式(Part 6.3)。 +# +# 用法:./rebuild-plugins.sh <插件名>... +# +# 关键性质:**不修改任何插件源码**。每个插件只需用新版 plugindev 重编, +# plg.json 的 entry 仍写着 "plugin.so" 也无妨——工具链已不看这个字段。 +set -uo pipefail + +PLUGINDEV=${PLUGINDEV:-/tmp/plugindev} +EXAMPLE_DIR=${EXAMPLE_DIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)/third_party/homeagent-sdk/example"} +export GOCACHE=${GOCACHE:-/tmp/gocache} +export GOPATH=${GOPATH:-/tmp/gopath} + +if [ ! -x "$PLUGINDEV" ]; then + echo "plugindev 不存在或不可执行: $PLUGINDEV" >&2 + exit 1 +fi + +ok=0 +fail=0 +failed_names="" + +for name in "$@"; do + dir="$EXAMPLE_DIR/$name" + if [ ! -d "$dir" ]; then + echo "✗ $name: 目录不存在" + fail=$((fail + 1)) + failed_names="$failed_names $name" + continue + fi + + # 清理旧 C ABI 产物:同目录残留 .so 不影响构建,但会让人误以为还在用旧通道 + rm -rf "$dir/build" "$dir/dist" + + out=$(cd "$dir" && "$PLUGINDEV" build 2>&1) + rc=$? + + # 判定成功的依据是产物存在,而非退出码:plugindev 对部分错误只打印不退出 + if [ $rc -eq 0 ] && ls "$dir"/build/plugin.bin* >/dev/null 2>&1; then + n=$(ls "$dir"/build/plugin.bin* 2>/dev/null | wc -l) + hmap=$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1) + printf "✓ %-14s %s 个平台产物 %s\n" "$name" "$n" "$(basename "${hmap:-无 hmap}")" + ok=$((ok + 1)) + else + printf "✗ %-14s 构建失败\n" "$name" + echo "$out" | tail -6 | sed 's/^/ /' + fail=$((fail + 1)) + failed_names="$failed_names $name" + fi +done + +echo +echo "成功 $ok / 失败 $fail" +[ -n "$failed_names" ] && echo "失败:$failed_names" +exit $([ $fail -eq 0 ] && echo 0 || echo 1) diff --git a/internal/plugins/real_plugin_smoke_test.go b/internal/plugins/real_plugin_smoke_test.go new file mode 100644 index 0000000..73283ea --- /dev/null +++ b/internal/plugins/real_plugin_smoke_test.go @@ -0,0 +1,258 @@ +//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 +}