Files
HomeAgent/internal/plugin/entry_dispatch_test.go
dev 610e9d0bbb feat(plugin): entry 双通道分派 + 共享内存 stage 数据面(Part 1 + Part 4 核心)
Part 1 加载分派骨架(迁移可逐插件推进、随时回退的前提):
- dynamic.go: 新增 binEntry/skillEntry 常量 + entryKind 枚举 + classifyEntry/detectEntryKind
  manifest entry 优先级最高(改回 plugin.so 即回退 cabi);无 manifest 时按目录探测,.bin 优先
- registry.go: tryDynamic 按 entry 分派 proc/cabi 双通道;
  entry 声明 .bin 但二进制缺失时报明确错误,不静默回退(否则'已迁移插件跑回旧通道'极难排查)
- registry.go: pluginEntryHash 候选顺序与 detectEntryKind 对齐(.bin 优先),
  否则增量重载会用错文件算 hash
- dynamic_proc_{unix,windows}.go: tryLoadProc 桩位(权限/类型校验已实现,进程管理属 Part 2)

Part 4 共享内存数据面(迁移评估 §3.3/§3.4/§3.7,最关键一环):
- proc/shm.go: 段布局(Header + ShmStageCtx 描述符数组 + append-only arena)
  相对偏移设计——各进程 mmap 到不同虚拟地址仍能正确解引用
  arena 用尽显式报错而非静默截断(§4.4 风险登记);Compact() 回收 append-only 垃圾
- proc/shmcodec.go: StageContext 16 字段跨进程编解码
  字段级描述符消除 lost update:只改 FinalText 的插件不触碰 ToolResults 描述符
  WriteDirty 只写脏字段——只读插件零写入,不可能覆盖他人改写
  Snapshot 存序列化字符串(切片共享底层数组的坑,C ABI 侧修 11.3 时已踩过)
  Extra 4 键提升为具名字段;Response 用标志位表达 nil vs 空串
- proc/lock.go: 锁仲裁回归内核(§3.7 已裁定,零 cgo)
  ForceRelease 实现实验 9 的崩溃自愈——排除 robust pthread_mutex 必要性
  重复加锁显式拒绝(否则死锁 30s);等待超时有补偿 goroutine 防锁泄漏

验证:
- proc 包 16 项测试全绿(含 -race):全字段往返/只读零写回/原地改切片识别/
  现网 sanitizer+weather 场景/5插件×40轮并发零丢失/arena 耗尽报错/压实不破坏字段/
  锁互斥·串扰拒绝·崩溃自愈·临界区串行化
- entry 分派 9 项测试全绿;go build ./... exit 0;接口冻结 git diff sdk/ 为空
2026-09-02 10:41:18 +08:00

161 lines
5.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.

package plugin
import (
"os"
"path/filepath"
"testing"
)
// entry 分派骨架docs/zh/plugin-migration-plan.md Part 1
// 外部插件多进程化期间 .so/.dllcabi与 .binproc双通道共存
// 按 plugin.json 的 entry 字段分派,使迁移可逐插件推进、随时回退。
func TestClassifyEntry(t *testing.T) {
cases := []struct {
entry string
want entryKind
}{
{"plugin.so", entryCABI},
{"plugin.dll", entryCABI},
{"plugin.dylib", entryCABI},
{"plugin.bin", entryProc},
{"main.lua", entryLua},
{"SKILL.md", entrySkill},
{"", entryUnknown},
{"plugin.wasm", entryUnknown},
}
for _, c := range cases {
if got := classifyEntry(c.entry); got != c.want {
t.Errorf("classifyEntry(%q) = %v, want %v", c.entry, got, c.want)
}
}
}
// manifest 显式声明的 entry 优先级最高。
func TestDetectEntryKind_ManifestWins(t *testing.T) {
dir := t.TempDir()
// 目录里放 .so但 manifest 声明 .bin → 应走 proc
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"plugin.bin"}`)
if got := detectEntryKind(dir); got != entryProc {
t.Fatalf("manifest 声明 plugin.bin 应走 proc实际 %v", got)
}
}
// manifest 声明 .so 时即便存在 .bin 也走 cabi —— 这是回退路径的保证。
func TestDetectEntryKind_ManifestCanForceRollback(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
mustWrite(t, filepath.Join(dir, metaEntry), `{"name":"x","entry":"plugin.so"}`)
if got := detectEntryKind(dir); got != entryCABI {
t.Fatalf("manifest 声明 plugin.so 应回退到 cabi实际 %v", got)
}
}
// 无 manifest或 entry 为空)时按目录探测,.bin 优先于 .so
// 迁移期间同目录可能两种产物共存(升级未清理),此时应走新通道。
func TestDetectEntryKind_ProbeOrderPrefersBin(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "fake so")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "fake bin")
if got := detectEntryKind(dir); got != entryProc {
t.Fatalf("无 manifest 时应优先 plugin.bin实际 %v", got)
}
}
func TestDetectEntryKind_ProbeFallbacks(t *testing.T) {
t.Run("only so", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "x")
if got := detectEntryKind(dir); got != entryCABI {
t.Fatalf("got %v", got)
}
})
t.Run("only lua", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "main.lua"), "x")
if got := detectEntryKind(dir); got != entryLua {
t.Fatalf("got %v", got)
}
})
t.Run("only skill", func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "SKILL.md"), "x")
if got := detectEntryKind(dir); got != entrySkill {
t.Fatalf("got %v", got)
}
})
t.Run("empty dir", func(t *testing.T) {
if got := detectEntryKind(t.TempDir()); got != entryUnknown {
t.Fatalf("空目录应为 unknown实际 %v", got)
}
})
}
// entry 声明 plugin.bin 但二进制缺失时必须报明确错误,
// 不得静默回退到 cabi —— 否则"已迁移插件跑回旧通道"极难排查。
func TestTryLoadProc_MissingBinaryReturnsNil(t *testing.T) {
dir := t.TempDir()
plg, err := tryLoadProc(dir, "demo", nil)
if plg != nil || err != nil {
t.Fatalf("无 plugin.bin 应返回 nil,nil交由后续探测实际 plg=%v err=%v", plg, err)
}
}
func TestTryLoadProc_NonExecutableRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, binEntry)
mustWrite(t, path, "not executable")
if err := os.Chmod(path, 0o644); err != nil {
t.Fatalf("chmod: %v", err)
}
_, err := tryLoadProc(dir, "demo", nil)
if err == nil {
t.Fatal("缺少可执行权限应报错")
}
}
// pluginEntryHash 的候选顺序须与 detectEntryKind 一致plugin.bin 优先),
// 否则增量重载会用错文件算 hash导致"换了 .bin 但内核以为没变"。
func TestPluginEntryHash_PrefersBin(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content")
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content")
h1 := pluginEntryHash(dir)
if h1 == "" {
t.Fatal("应算出 hash")
}
// 改 .so 不应影响 hash因为以 .bin 为准)
mustWrite(t, filepath.Join(dir, "plugin.so"), "so content CHANGED")
if h2 := pluginEntryHash(dir); h2 != h1 {
t.Error("plugin.bin 存在时 hash 不应受 plugin.so 变化影响")
}
// 改 .bin 必须改变 hash
mustWrite(t, filepath.Join(dir, "plugin.bin"), "bin content CHANGED")
if h3 := pluginEntryHash(dir); h3 == h1 {
t.Error("plugin.bin 变化必须反映到 hash否则增量重载失效")
}
}
func TestPluginEntryHash_EmptyForFactoryOnlyPlugin(t *testing.T) {
if h := pluginEntryHash(t.TempDir()); h != "" {
t.Errorf("无入口文件应返回空串(内置纯工厂插件),实际 %q", h)
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("写 %s: %v", path, err)
}
}