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
This commit is contained in:
JianFeeeee
2026-09-02 20:20:25 +08:00
parent b20121f703
commit 1d7f011e5d
4 changed files with 479 additions and 0 deletions

View File

@ -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-0215 个真实插件)
```
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.8MBbrowser 依赖最多)。
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 而非 failCI 不强制先跑重编脚本。

View File

@ -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 <homed-pid>
set -uo pipefail
pid=${1:-}
if [ -z "$pid" ]; then
echo "用法: $0 <homed-pid>" >&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.2MBbrowser 依赖最多)。"
echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——"
echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。"

View File

@ -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)

View File

@ -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 改写 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
}