mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 08:58:03 +00:00
## entry 不再是通道开关 —— 外部插件零改动的关键 17 个存量插件的 plg.json 都写着 "entry": "plugin.so"。若把 entry 当通道 开关,迁移就得改 17 个文件,而「外部插件零改动」是本次迁移的硬约束。 改法:Go 插件一律产出 plugin.bin,不看 entry 值。isProcEntry 删除, resolveBuild 去掉 proc 参数。entry 现在只剩区分 Lua(main.lua)一个用途。 实测:weather 的 plg.json 一行不改(仍写 plugin.so),plugindev build 直接产出三平台 plugin.bin。 ## Windows 不再是能力退化的第三套实现(§9.2 的正解) C ABI 时代 Windows 是独立的第三套 ABI:dynamic_dll_windows.go 的 stage 只下发 3 个字段(raw_message/user_id/phase)且完全没有写回,sanitizer 这类改写型插件在 Windows 上静默失效,且无任何运行时警告。 现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局。平台差异 收敛到三个挂载函数: - Unix(linux/darwin/freebsd):内核经 ExtraFiles 传继承 fd(3=StageContext 段,4=事件环段,5=eventfd/pipe) - Windows:没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 不支持), 改用命名内核对象——父进程 CreateFileMapping/CreateEvent 建带名字的对象, 子进程 OpenFileMappingW/OpenEventW 按同名打开。名字经环境变量传入而非 硬编码:多个 homed 实例并存时不能撞名。 Windows 绑定用 syscall.NewLazyDLL 而非 golang.org/x/sys/windows: OpenFileMappingW/OpenEventW 未被标准库 syscall 导出,而引入 x/sys 会给 **每个插件的 go.mod** 加一个新依赖,违反「插件仅依赖公开 SDK」。 LazyDLL 属标准库,零新增依赖。 新增 evtWaiter 接口抽象等待语义:eventfd 是计数器(多事件合并成一次 唤醒),Windows Event 是二元信号。不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq 批量 drain,一次唤醒能处理累积的全部事件。 模板拆成三个文件: proc_main.go.tmpl 平台无关(RPC + 共享段布局 + stage + 事件环消费) proc_shm_unix.go.tmpl 继承 fd 挂载 proc_shm_windows.go.tmpl 命名对象挂载 ## 删除 C ABI 工具链 templates.go 1296 → 516 行: - tmplBridge(Windows DLL bridge) -265 行 - tmplLinuxBridge(Linux c-shared) -457 行 - tmplPluginInitC(C 入口) -57 行 另删 generateBridge / detectWindowsCC(MinGW 探测)/ tmplCABIHeader / InitData.CABIVersion+CABIHeader。 交叉编译不再需要目标平台 C 工具链——这是 -buildmode=c-shared 退场的 连带收益(§3.1)。 ## 测试 15 项全过,新增 4 项守护迁移不变量: - AllPlatformsProduceBin:6 个 GOOS/GOARCH 组合统一产出 plugin.bin - LuaIsSeparatePath:Lua 仍走解释器路径 - UnsupportedOSErrors:不支持平台明确报错,不静默产出错误产物 - NoCABIResiduals:代码中不得再出现 c-shared / CGO_ENABLED=1 / detectWindowsCC / tmplLinuxBridge / tmplPluginInitC(注释除外) - IgnoresEntryForGoPlugins:isProcEntry 必须已删除 验证:go build/vet/test 全通过;三平台交叉编译产出 plugin.bin; git diff sdk/ 为空(接口冻结)。 Ref: docs/zh/架构迁移评估.md §3.1/§9.2、docs/zh/plugin-migration-plan.md Part 6
395 lines
14 KiB
Go
395 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"go/parser"
|
||
"go/token"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 子进程运行时模板的静态检查(Part 3)。
|
||
//
|
||
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
|
||
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
|
||
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
|
||
//
|
||
// 模板改为真实 .go 源文件(而非 raw string)的直接收益就是这类检查可行。
|
||
|
||
func loadProcTemplate(t *testing.T) string {
|
||
t.Helper()
|
||
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
|
||
if err != nil {
|
||
t.Fatalf("读取内嵌模板: %v", err)
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
|
||
func stripComments(t *testing.T, src string) string {
|
||
t.Helper()
|
||
fs := token.NewFileSet()
|
||
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
|
||
if err != nil {
|
||
t.Fatalf("解析模板: %v", err)
|
||
}
|
||
out := []byte(src)
|
||
for _, cg := range f.Comments {
|
||
s := fs.Position(cg.Pos()).Offset
|
||
e := fs.Position(cg.End()).Offset
|
||
for i := s; i < e && i < len(out); i++ {
|
||
if out[i] != '\n' {
|
||
out[i] = ' '
|
||
}
|
||
}
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
// 模板必须是合法 Go 源码。
|
||
func TestProcTemplate_ParsesAsGo(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
fs := token.NewFileSet()
|
||
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
|
||
t.Fatalf("模板不是合法 Go 源码: %v", err)
|
||
}
|
||
}
|
||
|
||
// 模板必须提供 main(),且不得含 cgo 痕迹。
|
||
//
|
||
// 零 cgo 是迁移的核心收益之一(§3.7 锁仲裁回内核后整个架构无 cgo);
|
||
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
|
||
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
if !strings.Contains(src, "func main()") {
|
||
t.Error("子进程模板必须有 main() 入口")
|
||
}
|
||
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
|
||
code := stripComments(t, src)
|
||
for _, forbidden := range []string{
|
||
`import "C"`,
|
||
"//export ",
|
||
"C.CString",
|
||
"C.GoString",
|
||
"C.free",
|
||
} {
|
||
if strings.Contains(code, forbidden) {
|
||
t.Errorf("模板不应含 cgo 痕迹 %q(零 cgo 是迁移的核心收益)", forbidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
|
||
//
|
||
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
|
||
// 表现是插件调用返回「未知 method」,测试能提前拦住。
|
||
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
// 51 个 C ABI method id 平移后的名字(§3.2),加 stage 锁仲裁 2 个
|
||
required := []string{
|
||
// 注册面
|
||
"tool.register", "stage.register", "output.register", "api.register", "input.register",
|
||
// IO 注入
|
||
"io.injectText", "io.injectInterrupt", "io.injectTextNoMem", "io.injectInputSync",
|
||
"io.setToolBlocks",
|
||
// 生命周期
|
||
"lifecycle.autoRestart",
|
||
// 图记忆
|
||
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
|
||
// 文档记忆
|
||
"doc.query", "doc.insert", "doc.remove", "doc.stats",
|
||
// 知识库
|
||
"knowledge.search", "knowledge.add", "knowledge.list",
|
||
// 文本记忆
|
||
"textmemory.append",
|
||
// 设置
|
||
"settings.get", "settings.set", "settings.registerDef",
|
||
"settings.getCore", "settings.setCore", "settings.listCore",
|
||
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
|
||
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
|
||
"settings.dataDir",
|
||
// LLM
|
||
"llm.listSources", "llm.setSource", "llm.currentSource",
|
||
// 社交图
|
||
"social.getPerson", "social.getNetwork", "social.getTrait",
|
||
"social.getRelations", "social.listPersons",
|
||
// 插件管理
|
||
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
|
||
// 共享段锁仲裁(新增,C ABI 下不存在此概念)
|
||
"stage.lock", "stage.unlock",
|
||
}
|
||
for _, m := range required {
|
||
if !strings.Contains(src, `"`+m+`"`) {
|
||
t.Errorf("模板缺少 core method %q(内核已提供,插件侧未接线)", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 模板必须处理内核发来的全部 7 个调用(原 C ABI 的 7 个 //export)。
|
||
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
for _, m := range []string{
|
||
"handshake",
|
||
"plugin.init", "plugin.start", "plugin.stop",
|
||
"tool.invoke", "stage.invoke", "output.invoke",
|
||
} {
|
||
if !strings.Contains(src, `case "`+m+`"`) {
|
||
t.Errorf("模板未处理内核调用 %q", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
|
||
//
|
||
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
|
||
// 而两边都不报错(同为 []byte)。
|
||
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
|
||
layout := map[string]string{
|
||
"shmOffMagic": "0",
|
||
"shmOffVersion": "4",
|
||
"shmOffArenaBase": "8",
|
||
"shmOffArenaCap": "12",
|
||
"shmOffArenaUsed": "16",
|
||
"shmOffCtxBase": "20",
|
||
"shmOffSeq": "24",
|
||
// 与内核 stageFieldCount / sliceSize 对齐
|
||
"shmStageFieldCount": "18",
|
||
"shmSliceSize": "8",
|
||
"shmVersion": "1",
|
||
}
|
||
constRe := func(name, want string) bool {
|
||
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
|
||
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
|
||
return re.MatchString(src)
|
||
}
|
||
for name, want := range layout {
|
||
if !constRe(name, want) {
|
||
t.Errorf("共享段常量 %s 应为 %s(须与内核 internal/plugin/proc/shm.go 一致)", name, want)
|
||
}
|
||
}
|
||
|
||
// 字段枚举顺序:内核 stageField 的前若干项
|
||
fieldOrder := []string{
|
||
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
|
||
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
|
||
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
|
||
"fTokenUsage", "fErrors",
|
||
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
|
||
}
|
||
idx := -1
|
||
for _, f := range fieldOrder {
|
||
at := strings.Index(src, f)
|
||
if at < 0 {
|
||
t.Fatalf("模板缺少字段常量 %s", f)
|
||
}
|
||
if at <= idx {
|
||
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
|
||
}
|
||
idx = at
|
||
}
|
||
}
|
||
|
||
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
|
||
//
|
||
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
|
||
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
|
||
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
for _, want := range []string{
|
||
"func handleStageInvoke(",
|
||
"stage.lock",
|
||
"readStageContext()",
|
||
"takeStageSnapshot(",
|
||
"writeStageDirty(",
|
||
"stage.unlock",
|
||
} {
|
||
if !strings.Contains(src, want) {
|
||
t.Errorf("stage 处理链路缺少 %q", want)
|
||
}
|
||
}
|
||
|
||
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
|
||
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
|
||
iRead := strings.Index(src, "readStageContext()")
|
||
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
|
||
if iLock < 0 || iRead < 0 || iWrite < 0 {
|
||
t.Fatal("stage 链路关键调用缺失")
|
||
}
|
||
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
|
||
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
|
||
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
|
||
iReadFn := strings.Index(stageFn, "readStageContext()")
|
||
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
|
||
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
|
||
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
|
||
}
|
||
}
|
||
|
||
// 快照必须存序列化字符串而非 Go 值。
|
||
//
|
||
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑:StageContext 的切片字段与读出的值
|
||
// 共享底层内容,handler 原地改元素(sc.ToolResults[0].Result = x)时,
|
||
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
|
||
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
|
||
if !strings.Contains(src, "strs map[int]string") ||
|
||
!strings.Contains(src, "jsons map[int]string") {
|
||
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
|
||
}
|
||
if !strings.Contains(src, "json.Marshal(v)") {
|
||
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
|
||
}
|
||
}
|
||
|
||
// arena 用尽必须显式报错,不得静默截断(§4.4 风险登记)。
|
||
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
if !strings.Contains(src, "arena 空间不足") {
|
||
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
|
||
}
|
||
}
|
||
|
||
// 日志必须走 stderr:stdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
|
||
func TestProcTemplate_LogsToStderr(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
|
||
t.Error("日志必须走 stderr,否则会破坏 stdout 的 RPC 帧")
|
||
}
|
||
}
|
||
|
||
// 请求必须在独立 goroutine 里处理。
|
||
//
|
||
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
|
||
// 就没人读应答帧 → 死锁。
|
||
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
if !strings.Contains(src, "go handleKernelRequest(&req)") {
|
||
t.Error("请求须在独立 goroutine 处理(handler 内反向调用内核,同步处理会死锁)")
|
||
}
|
||
}
|
||
|
||
// 协议与共享段版本不匹配必须拒绝,不得半兼容运行。
|
||
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
|
||
src := loadProcTemplate(t)
|
||
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "共享段魔数不匹配"} {
|
||
if !strings.Contains(src, want) {
|
||
t.Errorf("握手应校验并拒绝 %q", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 全平台统一产出 plugin.bin。
|
||
//
|
||
// 这是三套独立 ABI 实现(.so/.dylib/.dll)收敛为单一 RPC 实现的直接后果:
|
||
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
|
||
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
|
||
// Windows 走的是与 Linux 完全相同的 RPC 实现。
|
||
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
|
||
for _, target := range []string{
|
||
"linux/amd64", "linux/arm64",
|
||
"darwin/amd64", "darwin/arm64",
|
||
"windows/amd64",
|
||
"freebsd/amd64",
|
||
} {
|
||
cfg, errMsg := resolveBuild(target)
|
||
if cfg == nil {
|
||
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
|
||
}
|
||
if cfg.entryFile != procEntryFile {
|
||
t.Errorf("%s: 产物应为 %s,实际 %s", target, procEntryFile, cfg.entryFile)
|
||
}
|
||
}
|
||
}
|
||
|
||
// lua 目标仍走解释器路径(entry 字段唯一仍在使用的用途)。
|
||
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
|
||
for _, target := range []string{"lua", ""} {
|
||
cfg, kind := resolveBuild(target)
|
||
if cfg != nil {
|
||
t.Errorf("%q 应返回 nil cfg(Lua 不经 Go 编译)", target)
|
||
}
|
||
if kind != "lua" {
|
||
t.Errorf("%q 应识别为 lua,实际 %q", target, kind)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 不支持的平台明确报错,不静默产出错误产物。
|
||
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
|
||
cfg, errMsg := resolveBuild("plan9/amd64")
|
||
if cfg != nil {
|
||
t.Error("不支持的平台应返回 nil cfg")
|
||
}
|
||
if !strings.Contains(errMsg, "unsupported") {
|
||
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
|
||
}
|
||
}
|
||
|
||
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
|
||
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
|
||
seen := map[string]bool{}
|
||
for _, bt := range allBundleTargets {
|
||
if seen[bt.entry] {
|
||
t.Errorf("zip 条目名重复: %s(会相互覆盖)", bt.entry)
|
||
}
|
||
seen[bt.entry] = true
|
||
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
|
||
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
|
||
}
|
||
}
|
||
if len(allBundleTargets) == 0 {
|
||
t.Error("bundle 目标表不应为空")
|
||
}
|
||
}
|
||
|
||
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
|
||
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
|
||
func TestToolchain_NoCABIResiduals(t *testing.T) {
|
||
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
|
||
data, err := os.ReadFile(f)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", f, err)
|
||
}
|
||
src := stripComments(t, string(data))
|
||
for _, forbidden := range []string{
|
||
"c-shared",
|
||
"CGO_ENABLED=1",
|
||
"detectWindowsCC",
|
||
"generateBridge",
|
||
"tmplLinuxBridge",
|
||
"tmplPluginInitC",
|
||
} {
|
||
if strings.Contains(src, forbidden) {
|
||
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Go 插件的构建不再读 plg.json 的 entry 值。
|
||
//
|
||
// 这是「外部插件零改动」的关键:17 个存量插件的 plg.json 都写着 "plugin.so",
|
||
// 若把 entry 当通道开关,迁移就得改 17 个文件。
|
||
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
|
||
data, err := os.ReadFile("cmd_build.go")
|
||
if err != nil {
|
||
t.Fatalf("读 cmd_build.go: %v", err)
|
||
}
|
||
src := stripComments(t, string(data))
|
||
if strings.Contains(src, "isProcEntry") {
|
||
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin,不看 entry 值")
|
||
}
|
||
// entry 仅剩 Lua 判定这一处用途
|
||
if !strings.Contains(src, "luaEntryFile") {
|
||
t.Error("IsLua 应改用 luaEntryFile 常量")
|
||
}
|
||
}
|