Files
HomeAgent/internal/plugins/pluginmgr/upgrade_test.go
JianFeeeee b20121f703 plugin: 删除 C ABI 通道(Part 6.2 完成,-3198 行)
外部插件统一走子进程 + stdio RPC,三套独立 ABI 实现收敛为单一 RPC 实现。
用户决策:彻底舍弃 .so 能力,不保留双通道回退。

## 删除清单

internal/plugin/cabi/                     1156 行(loader.go/loader.c/types.go/output_test.go)
internal/plugin/dynamic_dll_windows.go     272 行(§9.2 记录的能力退化实现)
internal/plugin/dynamic_loader_unix.go      79 行(唯一 cabi 引用点)
internal/plugin/dynamic_dll_test.go         32 行
internal/plugin/dynamic_dll_stub.go         11 行
internal/plugin/dynamic_loader_windows.go   11 行
internal/plugin/bridge_e2e_test.go              (测的是 cabi 路径)
third_party/.../plugindev/templates.go    1296 行(取消跟踪,SDK 仓才是权威副本)

dynamic.go:entryCABI 通道删除,soEntry/dllEntry 常量删除。
registry.go:tryDynamic 探测顺序从 .so → .dll → .lua 变成 proc → lua。

## 旧 .so 给明确错误,不静默跳过

静默跳过会让「插件目录在但没加载」看起来像配置问题,而实际原因是需要
用新版 plugindev 重编。故保留 legacyCABIEntries 表专门用于识别残留:

  plugin legacy: 检测到旧 C ABI 产物(plugin.so/.dll/.dylib)。
  外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin
  (业务代码无需修改)

错误消息里「业务代码无需修改」这句是有测试守着的——迁移的核心承诺就是它。

## pluginmgr 安装逻辑跟进 bundle 命名

子进程模式下各平台产物统一叫 plugin.bin(进程边界即 ABI 边界),故 zip 内
按平台加后缀 plugin.bin.<goos>.<goarch>,解包时挑当前平台那一份重命名。

platformBinary 改为按 runtime.GOOS+GOARCH 生成条目名;platformBinaries 固定表
换成 isPlatformBinary 前缀判断(平台组合会增长:linux/arm64、darwin/arm64…,
按前缀判断无需维护清单)。

新增 chmod 0755:zip 保留了原权限位,但经某些工具链/传输后可能丢失,
内核加载时会因缺执行位报错。提前补上比事后让用户 chmod 更好。

## 测试

entry_dispatch_test.go 重写(12 项):
- classifyEntry 对 .so/.dll/.dylib 现在返回 unknown
- LegacyManifestFallsBackToProbe:存量插件 manifest 仍写 "plugin.so"
  (17 个插件没人去改),须靠目录探测找到 plugin.bin —— 这是
  「外部插件零改动」的直接后果
- LegacyCABIGivesActionableError:错误消息须含 plugindev / plugin.bin / 业务代码
- PluginEntryHash_IgnoresLegacyCABI:.so 不参与 hash(内核已不认它)

upgrade_test.go 的 .hmap 构造改用 plugin.bin。

验证:go build ./... 通过;go test ./... 全仓无失败;
go test -race ./internal/plugin/... 全绿;三平台构建通过。

Ref: docs/zh/架构迁移评估.md §3.1/§9.2、docs/zh/plugin-migration-plan.md Part 6
2026-09-02 19:26:40 +08:00

221 lines
7.8 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.

package pluginmgr
import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestCmpVersion(t *testing.T) {
cases := []struct{ a, b string; want int }{
{"1.0.0", "1.0.0", 0},
{"1.0.1", "1.0.0", 1},
{"1.0.0", "1.0.1", -1},
{"1.0", "1.0.0", 0},
{"v2.0.0", "1.9.9", 1},
{"2.0.0", "10.0.0", -1}, // 数字比较而非字典序
{"1.0.0-alpha", "1.0.0", 0}, // 非数字段按 0
}
for _, c := range cases {
if got := cmpVersion(c.a, c.b); got != c.want {
t.Errorf("cmpVersion(%q,%q)=%d want %d", c.a, c.b, got, c.want)
}
}
}
// ---- 最小 mock SDK ----
type pmSettings struct{}
func (m *pmSettings) DataDir() string { return "/tmp/mock_data" }
func (m *pmSettings) Get(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) Set(string, interface{}) error { return nil }
func (m *pmSettings) List(string) ([]string, error) { return nil, nil }
func (m *pmSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (m *pmSettings) SetCore(string, interface{}) error { return nil }
func (m *pmSettings) ListCore(string) ([]string, error) { return nil, nil }
func (m *pmSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (m *pmSettings) SetPlugin(string, string, interface{}) error { return nil }
func (m *pmSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (m *pmSettings) RegisterDef(sdk.ConfigDef) {}
func (m *pmSettings) Defs(string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) Dump() map[string]interface{} { return nil }
func (m *pmSettings) Plugins() []string { return nil }
func (m *pmSettings) DefsCore(string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) DefsPlugin(string, string) []*sdk.ConfigDef { return nil }
func (m *pmSettings) Remove(string) error { return nil }
func (m *pmSettings) RemoveCore(string) error { return nil }
func (m *pmSettings) RemovePlugin(string, string) error { return nil }
// fakePluginMgr 记录调用StopAndUnload 只记标志,不真正操作。
type fakePluginMgr struct {
mu sync.Mutex
stopAndUnloads []string
}
func (f *fakePluginMgr) ListLoadedPlugins() []string { return nil }
func (f *fakePluginMgr) ListDisabledPlugins() []sdk.DisabledPluginInfo { return nil }
func (f *fakePluginMgr) IsPluginDisabled(string) bool { return false }
func (f *fakePluginMgr) IsBuiltinPlugin(string) bool { return false }
func (f *fakePluginMgr) DisablePlugin(string, string) error { return nil }
func (f *fakePluginMgr) EnablePlugin(string) error { return nil }
func (f *fakePluginMgr) RemovePlugin(string) error { return nil }
func (f *fakePluginMgr) StopAndUnload(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.stopAndUnloads = append(f.stopAndUnloads, name)
return nil
}
func (f *fakePluginMgr) ReloadPlugins() (string, error) { return "", nil }
func (f *fakePluginMgr) ReloadOne(string) error { return nil }
func (f *fakePluginMgr) PluginMetas() map[string]sdk.PluginMeta {
return map[string]sdk.PluginMeta{}
}
func (f *fakePluginMgr) PluginDir() string { return "" }
// buildHmap 构造一个最小 .hmap 包。
func buildHmap(t *testing.T, name, version string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
manifest := map[string]interface{}{
"name": name, "name_zh": name, "name_en": name,
"version": version, "entry": "plugin.bin",
}
mData, _ := json.Marshal(manifest)
f, _ := zw.Create("plugin.json")
f.Write(mData)
bin, _ := zw.Create("plugin.bin")
bin.Write([]byte("binary-" + name + "-" + version))
zw.Close()
return buf.Bytes()
}
func TestInstallThenUpgradeKeepsConfig(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
PluginMgr: fm,
})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
// 1. 首次安装 v1.0.0
r1, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false)
m1 := r1.(map[string]interface{})
if m1["status"] != "installed" {
t.Fatalf("install failed: %v", m1)
}
if _, err := os.Stat(filepath.Join(dir, "demo", "plugin.json")); err != nil {
t.Fatalf("installed dir missing: %v", err)
}
// 2. 不带 overwrite 重装 → 报 already exists + remove_first hint
r2, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false)
m2 := r2.(map[string]interface{})
if m2["error"] != "plugin already exists" || m2["hint"] == "" {
t.Fatalf("expected already-exists with hint, got %v", m2)
}
if m2["current"] != "1.0.0" {
t.Fatalf("current version not reported: %v", m2)
}
// 3. overwrite 升级 v1.0.0 → v2.0.0
r3, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true)
m3 := r3.(map[string]interface{})
if m3["status"] != "installed" || m3["action"] != "upgraded" {
t.Fatalf("upgrade failed: %v", m3)
}
if m3["previous_version"] != "1.0.0" {
t.Fatalf("previous_version = %v", m3["previous_version"])
}
if m3["config_kept"] != true {
t.Fatalf("config_kept should be true: %v", m3)
}
// StopAndUnload 应被调用且不触发 RemovePlugin不删配置
fm.mu.Lock()
calls := append([]string{}, fm.stopAndUnloads...)
fm.mu.Unlock()
if len(calls) != 1 || calls[0] != "demo" {
t.Fatalf("StopAndUnload not called once with demo: %v", calls)
}
// 新二进制写入
binData, err := os.ReadFile(filepath.Join(dir, "demo", "plugin.bin"))
if err != nil {
t.Fatalf("read new bin: %v", err)
}
if string(binData) != "binary-demo-2.0.0" {
t.Fatalf("bin not overwritten: %q", string(binData))
}
// 4. 降级 v2.0.0 → v1.5.0
r4, _ := p.installFromData(buildHmap(t, "demo", "1.5.0"), true)
m4 := r4.(map[string]interface{})
if m4["action"] != "downgraded" {
t.Fatalf("downgrade action = %v", m4)
}
}
func TestExtractFailureRollsBack(t *testing.T) {
dir := t.TempDir()
fm := &fakePluginMgr{}
bus := events.NewBus()
sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{
Settings: &pmSettings{},
EventBus: bus,
PluginMgr: fm,
})
p := &Plugin{pluginDir: dir, sdk: sdkInst}
// 先装 v1.0.0
if r, _ := p.installFromData(buildHmap(t, "rollback", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" {
t.Fatal("install failed")
}
// 构造损坏包zip 但缺 plugin.jsonextractPackage 会失败)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, _ := zw.Create("plugin.bin")
f.Write([]byte("corrupt"))
zw.Close()
// 畸形包在 validatePackage 层就拒绝,未达 extract——模拟 extract 失败:
// 直接注入非法平台文件触发 extractPackage 错误
bad := buildHmap(t, "rollback", "9.9.9")
// 篡改使 extract 失败:附加一个越界路径
var rb bytes.Buffer
zw2 := zip.NewWriter(&rb)
f2, _ := zw2.Create("../../evil")
f2.Write([]byte("x"))
mf, _ := zw2.Create("plugin.json")
mData, _ := json.Marshal(map[string]interface{}{"name": "rollback", "version": "9.9.9", "entry": "plugin.bin"})
mf.Write(mData)
zw2.Close()
bad = rb.Bytes()
r, _ := p.installFromData(bad, true)
m := r.(map[string]interface{})
if m["error"] == nil {
t.Fatalf("expected error for corrupt package, got %v", m)
}
if m["rollback"] != nil {
t.Fatalf("rollback itself failed: %v", m)
}
// 旧版应被恢复
mfest, err := plugin.ReadManifest(filepath.Join(dir, "rollback"))
if err != nil || mfest.Version != "1.0.0" {
t.Fatalf("old version not restored: %v / %v", mfest, err)
}
}