From c0274b71d519fd6573fd1051062e359c1ffa63b5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 19:22:48 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=88=A0=E9=99=A4=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=9C=9F=E4=B8=B4=E6=97=B6=E6=96=87=E6=A1=A3=EF=BC=8C=E7=8E=B0?= =?UTF-8?q?=E8=A1=8C=E5=86=85=E5=AE=B9=E6=90=AC=E8=BF=9B=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户指出迁移评估那批是**过程性临时文档**,迁移已完成就该退场。 ## 删除(38 个文件) - docs/zh/架构迁移评估.md(1621 行)—— 评估稿。开头的「❗现网正在发生的问题」 (output_send 永远成功 / cgo 超时泄漏 26 次 / stage 污染)**全部已修复**, 留着是误导性告警。其 §三「目标架构」已被 ARCHITECTURE.md 完整覆盖 (且后者更细,含子进程生命周期管理)。 - docs/zh/plugin-interface-matrix.md(428 行)—— 迁移基线矩阵。 - docs/zh/experiments/(36 文件)—— 18 项可行性实验,验证的是"该不该迁移", 迁移早已完成;实测无任何构建/测试依赖它。 ## 现行内容先搬走(不能随临时文档一起丢) - plugin-interface-matrix §九「接口扩展规则」→ 搬进 docs/git-branching.md 新增 §八 (只增不减/签名不改、新增必须"插件调用内核实现"方向、hmapdev 模板必须同步接线 否则全体插件编译失败、"接口纯追加"≠"无需重编"、合回 main 的同步清单)。 - git-branching §六 原写「接口冻结是合回门禁」—— 冻结是**迁移期**约束,v1.1.x 起 已到期,改为标注失效并指向 §八。 ## 引用清理 8 处引用全部改指现行文档:plan.md ×3、两篇设计文档各 ×1、 4 处源码注释(proc/shm.go、proc/process.go、dynamic_proc.go、entry_dispatch_test.go、 proc/bench_test.go)。仅 third_party(SDK 独立仓)保留 1 处,不动。 ## 验证 - `go build ./...` 通过;`go test ./internal/plugin/...` 两个包全绿 - 本项目文档**断链 0**(另 2 处断链在 oh_modules 第三方依赖内) --- docs/git-branching.md | 62 +- .../01-dlclose-nodelete/exp01a/main.go | 72 - .../01-dlclose-nodelete/exp01b/main.go | 49 - .../01-dlclose-nodelete/exp01c/main.go | 58 - .../01-dlclose-nodelete/probe_v1.c | 2 - .../01-dlclose-nodelete/probe_v2.c | 2 - .../plugin-arch/01-dlclose-nodelete/shim.c | 9 - .../plugin-arch/02-feasibility/exp10.go | 49 - .../plugin-arch/02-feasibility/exp11.go | 42 - .../plugin-arch/02-feasibility/exp11_plug.go | 12 - .../02-feasibility/exp1_eventfd.go | 58 - .../plugin-arch/02-feasibility/exp2_child.go | 41 - .../plugin-arch/02-feasibility/exp2_parent.go | 60 - .../plugin-arch/02-feasibility/exp3_child.go | 31 - .../plugin-arch/02-feasibility/exp3_parent.go | 37 - .../plugin-arch/02-feasibility/exp4.go | 71 - .../plugin-arch/02-feasibility/exp5.go | 55 - .../plugin-arch/02-feasibility/exp5_plugin.go | 23 - .../plugin-arch/02-feasibility/exp5b.go | 68 - .../plugin-arch/02-feasibility/exp6.go | 49 - .../plugin-arch/02-feasibility/exp6_crash.go | 23 - .../plugin-arch/02-feasibility/exp7.go | 63 - .../plugin-arch/02-feasibility/exp8.go | 84 - .../plugin-arch/02-feasibility/exp8_worker.go | 50 - .../plugin-arch/02-feasibility/exp9.go | 60 - .../plugin-arch/02-feasibility/exp9_worker.go | 12 - .../plugin-arch/03-lost-update/exp12/main.go | 90 - .../plugin-arch/03-lost-update/exp13/main.go | 101 - .../04-cgo-uninterruptible/exp14a/main.go | 62 - .../04-cgo-uninterruptible/exp14b/main.go | 43 - .../plugin-arch/04-cgo-uninterruptible/hang.c | 2 - .../plugin-arch/19-migration-verify/README.md | 137 -- .../measure-plugin-overhead.sh | 82 - .../19-migration-verify/rebuild-plugins.sh | 56 - .../19-migration-verify/switch-production.py | 132 -- docs/zh/experiments/plugin-arch/README.md | 111 -- docs/zh/experiments/plugin-arch/run.sh | 127 -- docs/zh/input-scheduler-design.md | 6 +- docs/zh/plugin-interface-matrix.md | 429 ----- docs/zh/resident-subagent-design.md | 2 +- docs/zh/架构迁移评估.md | 1621 ----------------- internal/plugin/dynamic_proc.go | 4 +- internal/plugin/entry_dispatch_test.go | 2 +- internal/plugin/proc/bench_test.go | 2 +- internal/plugin/proc/process.go | 2 +- internal/plugin/proc/shm.go | 3 +- plan.md | 14 +- 47 files changed, 76 insertions(+), 4094 deletions(-) delete mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01a/main.go delete mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01b/main.go delete mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01c/main.go delete mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v1.c delete mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v2.c delete mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/shim.c delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp10.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp11.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp1_eventfd.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp2_parent.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp3_parent.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp4.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp5.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp5_plugin.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp6.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp7.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp8.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp8_worker.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp9.go delete mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp9_worker.go delete mode 100644 docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go delete mode 100644 docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go delete mode 100644 docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14a/main.go delete mode 100644 docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14b/main.go delete mode 100644 docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/hang.c delete mode 100644 docs/zh/experiments/plugin-arch/19-migration-verify/README.md delete mode 100755 docs/zh/experiments/plugin-arch/19-migration-verify/measure-plugin-overhead.sh delete mode 100755 docs/zh/experiments/plugin-arch/19-migration-verify/rebuild-plugins.sh delete mode 100755 docs/zh/experiments/plugin-arch/19-migration-verify/switch-production.py delete mode 100644 docs/zh/experiments/plugin-arch/README.md delete mode 100755 docs/zh/experiments/plugin-arch/run.sh delete mode 100644 docs/zh/plugin-interface-matrix.md delete mode 100644 docs/zh/架构迁移评估.md diff --git a/docs/git-branching.md b/docs/git-branching.md index 023cae8..09609b7 100644 --- a/docs/git-branching.md +++ b/docs/git-branching.md @@ -322,10 +322,15 @@ git branch -d release/v1.0.x # tag 已保存历史, ## 六、本规范与「接口冻结」约束的关系 -- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。 -- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`)。 -- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七), - 并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag。 +> **接口冻结已到期(v1.1.x 起)**。冻结是**迁移期**的约束——它要保的是 +> 「换运行模型不动业务代码」,靠 `git diff third_party/homeagent-sdk/sdk/` 为空来守。 +> 迁移完成(v1.0.0 上生产)后该约束按时失效,取而代之的是 §八的三条演进规则。 +> 本节保留历史条款,但**不再作为合回门禁**。 + +- ~~feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)~~ + —— **已失效**。现改为:公开接口的改动必须满足 §八(只增不减、签名不改、模板接线)。 +- `internal/sdk` **不受冻结约束**,可自由扩展(此条仍成立); + 公开 SDK 接口指 `third_party/homeagent-sdk/sdk/`。 - **公开接口的改动本身是 feature,不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径, 再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release ——发布分支冻结功能(§2.3),接口是最典型的功能面。 @@ -456,3 +461,52 @@ GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=/dist/release \ → 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**, 不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。) + +--- + +## 八、公开 SDK 接口的演进规则 + +> 本节原在《外部插件接口不变矩阵》(迁移期临时文档,已随迁移完成删除)§九。 +> 那份文档记的是**迁移期**的约束("换运行模型不动业务代码",靠 +> `git diff third_party/homeagent-sdk/sdk/` 为空来守)。迁移完成后该约束**到期**—— +> 继续冻结等于让 SDK 永远停在迁移那天的能力面,多模态这类功能永远到不了插件手上。 +> 取代它的是下面三条更弱、但仍然硬的规则。 + +### 1. 只增不减,签名不改 + +新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。 + +实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数, +或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败, +而那些插件根本不关心媒体。 + +### 2. 新增方法必须是「插件调用、内核实现」方向 + +这是**存量插件不需要重编**的技术原因:`IOInjector` 新增方法后,插件只是 +*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法, +每个存量插件都会因未实现而编译失败。 + +### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败** + +公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的实现若不满足新接口, +每个外部插件都**编不过**——是硬失败,不是软降级。 + +完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 → +`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 → +测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。 +还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,漂移**不会被编译器抓到**。 + +### 4. 「接口纯追加」不等于「无需重编」 + +插件运行协议版本(`ProtocolVersion`)与 SDK 接口版本是**两件事**。 +协议升级(如 1.2.0 的 fd3 布局变更,不支持滚动升级)时,`ProtocolVersion` 不匹配 +会在握手时被明确拒绝并提示用配套 `hmapdev` 重编。 +必须把两者分开说,否则会被误读成"既然纯追加就还能用旧产物"。 + +### 5. 合回 main 前要同步的东西 + +1. 改动公开 SDK 接口面后,同步 SDK 仓的版本(§七)与 `SDKCompatibleVersion`; +2. 生成模板已接线(跑 `cd tools/hmapdev && go test ./...`,含 + `TestProcTemplate_CoversAllCoreMethods`); +3. 存量插件源码零改动(逐个 `cd example/ && go vet ./...`); +4. 并发安全(`go test -race -count=5 ./sdk/`)。 diff --git a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01a/main.go b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01a/main.go deleted file mode 100644 index fbe6a40..0000000 --- a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01a/main.go +++ /dev/null @@ -1,72 +0,0 @@ -//go:build ignore - -package main - -/* -#cgo LDFLAGS: -ldl -#include -#include -typedef const char* (*verfn)(void); -static const char* call_ver(void* f){ return ((verfn)f)(); } -*/ -import "C" -import ( - "fmt" - "os" - "unsafe" -) - -func main() { - // Go 用 dlopen 加载纯 C shim(shim 本身常驻,无所谓) - sp := C.CString("./shim.so") - shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL) - C.free(unsafe.Pointer(sp)) - if shim == nil { - fmt.Println("shim 加载失败:", C.GoString(C.dlerror())) - os.Exit(1) - } - openName := C.CString("shim_open") - closeName := C.CString("shim_close") - symName := C.CString("shim_sym") - shimOpen := C.dlsym(shim, openName) - shimClose := C.dlsym(shim, closeName) - shimSym := C.dlsym(shim, symName) - C.free(unsafe.Pointer(openName)) - C.free(unsafe.Pointer(closeName)) - C.free(unsafe.Pointer(symName)) - fmt.Printf("shim 就绪: open=%p close=%p sym=%p\n\n", shimOpen, shimClose, shimSym) - - // 直接用 dlopen/dlsym 调 shim 的三个函数(避免再写一层 C 包装) - load := func(path string) unsafe.Pointer { - cp := C.CString(path) - defer C.free(unsafe.Pointer(cp)) - return C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL) - } - ver := func(h unsafe.Pointer) string { - n := C.CString("probe_version") - defer C.free(unsafe.Pointer(n)) - f := C.dlsym(h, n) - if f == nil { return "" } - return C.GoString(C.call_ver(f)) - } - - fmt.Println("--- 场景: Go(带 NODELETE runtime) 加载/卸载纯 C 的第三层 so ---") - h1 := load("./probe.so") - fmt.Printf("1) dlopen probe.so handle=%p version=%s\n", h1, ver(h1)) - - rc := C.dlclose(h1) - fmt.Printf("2) dlclose rc=%d\n", int(rc)) - - // 换内容(V1 -> V2),同路径 - in, _ := os.ReadFile("probe_v2.so") - os.WriteFile("probe.so", in, 0755) - fmt.Println("3) 磁盘 probe.so 内容替换为 V2(同路径)") - - h2 := load("./probe.so") - fmt.Printf("4) 再 dlopen 同路径 handle=%p version=%s\n", h2, ver(h2)) - if h1 == h2 { - fmt.Println(" => 句柄相同:未卸载,仍是旧代码") - } else { - fmt.Println(" => 句柄不同:真正卸载并重新装载了新代码 ✅") - } -} diff --git a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01b/main.go b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01b/main.go deleted file mode 100644 index 8f32abc..0000000 --- a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01b/main.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build ignore - -package main - -/* -#cgo LDFLAGS: -ldl -#include -#include -typedef void* (*openfn)(const char*); -typedef int (*closefn)(void*); -static void* c_open(void* f, const char* p){ return ((openfn)f)(p); } -static int c_close(void* f, void* h){ return ((closefn)f)(h); } -*/ -import "C" -import ( - "fmt" - "os" - "strings" - "unsafe" -) - -func cnt(s string) int { - b, _ := os.ReadFile("/proc/self/maps") - n := 0 - for _, l := range strings.Split(string(b), "\n") { if strings.Contains(l, s) { n++ } } - return n -} - -func main() { - sp := C.CString("./shim.so") - shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL) - C.free(unsafe.Pointer(sp)) - no := C.CString("shim_open"); nc := C.CString("shim_close") - fo := C.dlsym(shim, no); fc := C.dlsym(shim, nc) - C.free(unsafe.Pointer(no)); C.free(unsafe.Pointer(nc)) - - // 经【纯 C shim】去 dlopen/dlclose Go c-shared 插件 - qp := C.CString("/home/newqqagent/plugins/qq/plugin.so") - h := C.c_open(fo, qp) - C.free(unsafe.Pointer(qp)) - fmt.Printf("经 C shim dlopen Go 插件 handle=%p 映射段=%d\n", h, cnt("qq/plugin.so")) - rc := C.c_close(fc, h) - fmt.Printf("经 C shim dlclose rc=%d 映射段=%d\n", int(rc), cnt("qq/plugin.so")) - if cnt("qq/plugin.so") > 0 { - fmt.Println("\n❌ 仍未卸载 —— NODELETE 属于目标 .so 本身,与谁调 dlopen 无关") - } else { - fmt.Println("\n✅ 卸载成功") - } -} diff --git a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01c/main.go b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01c/main.go deleted file mode 100644 index c1c0ed6..0000000 --- a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01c/main.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build ignore - -package main - -/* -#cgo LDFLAGS: -ldl -#include -#include -typedef char* (*verfn)(void); -static char* call_ver(void* f){ return ((verfn)f)(); } -*/ -import "C" -import ( - "fmt" - "os" - "strings" - "unsafe" -) - -func threads() int { - e, _ := os.ReadDir("/proc/self/task") - return len(e) -} -func rss() int { - b, _ := os.ReadFile("/proc/self/status") - for _, l := range strings.Split(string(b), "\n") { - if strings.HasPrefix(l, "VmRSS:") { - var k int - fmt.Sscanf(l, "VmRSS: %d kB", &k) - return k - } - } - return 0 -} -func main() { - base, baseT := rss(), threads() - fmt.Printf("基线: RSS=%dKB threads=%d\n\n", base, baseT) - src, _ := os.ReadFile("glv1.so") - os.MkdirAll("stress", 0755) - var hs []unsafe.Pointer - for i := 1; i <= 30; i++ { - p := fmt.Sprintf("stress/%010d-qq.so", 1700000000+i) - os.WriteFile(p, src, 0755) - cp := C.CString("./" + p) - h := C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL) - C.free(unsafe.Pointer(cp)) - if h == nil { fmt.Printf("第 %d 次失败\n", i); break } - hs = append(hs, h) - C.dlclose(h) // 模拟每次都尝试卸载(no-op) - if i%10 == 0 { - fmt.Printf("第 %2d 次重载: RSS=%dKB (+%dKB) threads=%d (+%d)\n", - i, rss(), rss()-base, threads(), threads()-baseT) - } - } - fmt.Printf("\n30 次重载后: RSS 增长 %dKB, 线程增长 %d\n", rss()-base, threads()-baseT) - fmt.Printf("每次重载均摊: RSS +%.1fKB, 线程 +%.2f\n", - float64(rss()-base)/30, float64(threads()-baseT)/30) -} diff --git a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v1.c b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v1.c deleted file mode 100644 index f937edb..0000000 --- a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v1.c +++ /dev/null @@ -1,2 +0,0 @@ -#include -const char* probe_version(void){ return "V1"; } diff --git a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v2.c b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v2.c deleted file mode 100644 index 908d970..0000000 --- a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v2.c +++ /dev/null @@ -1,2 +0,0 @@ -#include -const char* probe_version(void){ return "V2"; } diff --git a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/shim.c b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/shim.c deleted file mode 100644 index 9425842..0000000 --- a/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/shim.c +++ /dev/null @@ -1,9 +0,0 @@ -#include -#include -void* shim_open(const char* p){ - void* h = dlopen(p, RTLD_NOW|RTLD_LOCAL); - if(!h) printf(" [shim] open FAIL: %s\n", dlerror()); - return h; -} -int shim_close(void* h){ return dlclose(h); } -void* shim_sym(void* h, const char* n){ return dlsym(h, n); } diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp10.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp10.go deleted file mode 100644 index 2a519c7..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp10.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build ignore -package main - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "time" - - "golang.org/x/sys/unix" -) - -func main() { - fmt.Println("=== 实验 10:多媒体 payload —— 共享内存零拷贝 vs JSON base64 ===") - sizes := []int{100 * 1024, 1024 * 1024, 5 * 1024 * 1024} - for _, sz := range sizes { - img := make([]byte, sz) - for i := range img { img[i] = byte(i % 251) } - - // A. JSON + base64(当前 ContentBlock 的做法) - t0 := time.Now() - b64 := base64.StdEncoding.EncodeToString(img) - blob, _ := json.Marshal(map[string]string{"type": "image_url", "url": "data:image/png;base64," + b64}) - var back map[string]string - json.Unmarshal(blob, &back) - dec, _ := base64.StdEncoding.DecodeString(back["url"][22:]) - jsonDur := time.Since(t0) - - // B. 共享内存 arena(写入 + 偏移解引用,零拷贝读) - mfd, _ := unix.MemfdCreate("arena", 0) - unix.Ftruncate(mfd, int64(sz+4096)) - data, _ := unix.Mmap(mfd, 0, sz+4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) - t0 = time.Now() - copy(data[4096:], img) // 写 arena - view := data[4096 : 4096+sz] // 偏移解引用 = 零拷贝切片 - _ = view[sz-1] - shmDur := time.Since(t0) - unix.Munmap(data) - unix.Close(mfd) - - fmt.Printf("\n%s payload:\n", map[int]string{100*1024:"100KB", 1024*1024:"1MB", 5*1024*1024:"5MB"}[sz]) - fmt.Printf(" A JSON+base64: %8v 传输体积 %d B (+%.0f%%) 解出 %d B %s\n", - jsonDur, len(blob), float64(len(blob)-sz)/float64(sz)*100, len(dec), - map[bool]string{true:"✓",false:"✗"}[len(dec)==sz]) - fmt.Printf(" B 共享内存: %8v 传输体积 8 B (描述符) 零拷贝视图 %d B\n", shmDur, len(view)) - fmt.Printf(" → 加速 %.0fx, 体积节省 %.0f%%\n", - float64(jsonDur)/float64(shmDur), float64(len(blob)-8)/float64(len(blob))*100) - } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp11.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp11.go deleted file mode 100644 index 439995f..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp11.go +++ /dev/null @@ -1,42 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "os/exec" - "sort" - "time" -) - -type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` } -type Res struct{ ID int `json:"id"`; Result string `json:"result"` } - -func main() { - fmt.Println("=== 实验 11:工具调用 RPC 端到端延迟(实测 payload 中位 93B)===") - cmd := exec.Command("./plug11") - sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe() - cmd.Start() - enc := json.NewEncoder(bufio.NewWriter(sin)) - w := bufio.NewWriter(sin); enc = json.NewEncoder(w) - dec := json.NewDecoder(bufio.NewReader(sout)) - - args := json.RawMessage(`{"city":"hangzhou","days":3,"unit":"celsius","detail":true}`) - const N = 10000 - lat := make([]time.Duration, 0, N) - for i := 0; i < N; i++ { - t0 := time.Now() - enc.Encode(Req{ID: i, Method: "weather_query", Args: args}); w.Flush() - var r Res - if err := dec.Decode(&r); err != nil { break } - lat = append(lat, time.Since(t0)) - } - sin.Close(); cmd.Wait() - sort.Slice(lat, func(a,b int) bool { return lat[a] < lat[b] }) - p := func(q float64) time.Duration { return lat[int(float64(len(lat))*q)] } - fmt.Printf("样本 %d 次\n", len(lat)) - fmt.Printf(" p50 = %v\n p90 = %v\n p99 = %v\n max = %v\n", p(0.5), p(0.9), p(0.99), lat[len(lat)-1]) - fmt.Printf("\n对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ %.5f%%\n", - float64(p(0.5))/float64(3*time.Second)*100) -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go deleted file mode 100644 index 586342d..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build ignore -package main -import ("bufio";"encoding/json";"os") -type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` } -type Res struct{ ID int `json:"id"`; Result string `json:"result"` } -func main(){ - dec:=json.NewDecoder(bufio.NewReader(os.Stdin)) - w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w) - for { var q Req - if err:=dec.Decode(&q); err!=nil {return} - enc.Encode(Res{ID:q.ID, Result:`{"ok":true,"data":"` + string(q.Args) + `"}`}); w.Flush() } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp1_eventfd.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp1_eventfd.go deleted file mode 100644 index 89d1e33..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp1_eventfd.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build ignore -package main - -import ( - "fmt" - "os" - "runtime" - "sync" - "sync/atomic" - "time" - - "golang.org/x/sys/unix" -) - -func threads() int { e, _ := os.ReadDir("/proc/self/task"); return len(e) } - -func main() { - fmt.Println("=== 实验 1:eventfd 是否走 Go netpoller(只 park goroutine 不占 OS 线程)===") - base := threads() - fmt.Printf("基线线程数: %d (GOMAXPROCS=%d)\n\n", base, runtime.GOMAXPROCS(0)) - - const N = 200 // 模拟 200 个订阅者等待 - var wg sync.WaitGroup - var woke int64 - files := make([]*os.File, N) - - for i := 0; i < N; i++ { - efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC) - if err != nil { fmt.Println("eventfd 失败:", err); return } - f := os.NewFile(uintptr(efd), fmt.Sprintf("evt%d", i)) - files[i] = f - wg.Add(1) - go func(f *os.File) { - defer wg.Done() - buf := make([]byte, 8) - // 阻塞读:若走 netpoller 只 park goroutine - if _, err := f.Read(buf); err == nil { - atomic.AddInt64(&woke, 1) - } - }(f) - } - - time.Sleep(500 * time.Millisecond) // 让所有 goroutine 进入等待 - waiting := threads() - fmt.Printf("%d 个 goroutine 阻塞在 eventfd.Read 后:\n", N) - fmt.Printf(" 线程数 = %d (增长 %d)\n", waiting, waiting-base) - if waiting-base < 20 { - fmt.Println(" ✅ 走 netpoller:线程未随等待者数量增长") - } else { - fmt.Printf(" ❌ 退化为阻塞 syscall:每个等待者占一个 OS 线程\n") - } - - // 全部唤醒 - one := []byte{1,0,0,0,0,0,0,0} - for _, f := range files { f.Write(one) } - wg.Wait() - fmt.Printf("\n唤醒数 = %d/%d 唤醒后线程数 = %d\n", woke, N, threads()) -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go deleted file mode 100644 index f81e782..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build ignore -package main - -import ( - "encoding/binary" - "fmt" - "os" - "unsafe" - - "golang.org/x/sys/unix" -) - -// 子进程:fd 3 = eventfd(通知), fd 4 = shm 文件 -func main() { - efd := os.NewFile(3, "evt") - shmf := os.NewFile(4, "shm") - - data, err := unix.Mmap(int(shmf.Fd()), 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) - if err != nil { fmt.Println("CHILD mmap 失败:", err); os.Exit(1) } - fmt.Printf("CHILD: mmap 基址 = %p\n", unsafe.Pointer(&data[0])) - - buf := make([]byte, 8) - if _, err := efd.Read(buf); err != nil { - fmt.Println("CHILD read err:", err); os.Exit(1) - } - n := binary.LittleEndian.Uint64(buf) - fmt.Printf("CHILD: 被 eventfd 唤醒, 计数=%d\n", n) - - // 按偏移读:头部 16 字节 = {off uint32, len uint32, seq uint64} - off := binary.LittleEndian.Uint32(data[0:4]) - ln := binary.LittleEndian.Uint32(data[4:8]) - seq := binary.LittleEndian.Uint64(data[8:16]) - payload := string(data[off : off+ln]) - fmt.Printf("CHILD: 偏移解引用 off=%d len=%d seq=%d → %q\n", off, ln, seq, payload) - - // 子进程回写(验证双向可见) - copy(data[2048:], []byte("CHILD-ACK")) - binary.LittleEndian.PutUint32(data[16:20], 2048) - binary.LittleEndian.PutUint32(data[20:24], uint32(len("CHILD-ACK"))) - fmt.Println("CHILD: 已回写 ACK") -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp2_parent.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp2_parent.go deleted file mode 100644 index ad3dc34..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp2_parent.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build ignore -package main - -import ( - "encoding/binary" - "fmt" - "os" - "os/exec" - "time" - "unsafe" - - "golang.org/x/sys/unix" -) - -func main() { - fmt.Println("=== 实验 2:跨进程 eventfd 通知 + 共享内存偏移解引用 ===") - - // eventfd 不带 CLOEXEC(需要被子进程继承) - efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK) - if err != nil { panic(err) } - evtFile := os.NewFile(uintptr(efd), "evt") - - // shm: 用 memfd(匿名,无需 /dev/shm 清理) - mfd, err := unix.MemfdCreate("stagectx", 0) - if err != nil { panic(err) } - if err := unix.Ftruncate(mfd, 4096); err != nil { panic(err) } - shmFile := os.NewFile(uintptr(mfd), "shm") - - data, err := unix.Mmap(mfd, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) - if err != nil { panic(err) } - fmt.Printf("PARENT: mmap 基址 = %p\n", unsafe.Pointer(&data[0])) - - // 写 payload 到 arena(偏移 1024),头部记描述符 - msg := "hello-from-parent-via-offset" - copy(data[1024:], []byte(msg)) - binary.LittleEndian.PutUint32(data[0:4], 1024) - binary.LittleEndian.PutUint32(data[4:8], uint32(len(msg))) - binary.LittleEndian.PutUint64(data[8:16], 42) - fmt.Printf("PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:%d, seq:42}\n", len(msg)) - - cmd := exec.Command("go", "run", "exp2_child.go") - cmd.ExtraFiles = []*os.File{evtFile, shmFile} // → 子进程 fd 3, 4 - cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr - if err := cmd.Start(); err != nil { panic(err) } - - time.Sleep(3 * time.Second) // 等 go run 编译+启动 - fmt.Println("PARENT: 数据到位后 post eventfd(不等待消费者)") - t0 := time.Now() - evtFile.Write([]byte{1,0,0,0,0,0,0,0}) - fmt.Printf("PARENT: post 耗时 %v ← post-and-forget\n", time.Since(t0)) - - cmd.Wait() - - // 读子进程回写 - off := binary.LittleEndian.Uint32(data[16:20]) - ln := binary.LittleEndian.Uint32(data[20:24]) - if ln > 0 { - fmt.Printf("PARENT: 读到子进程回写 → %q ✅ 双向可见\n", string(data[off:off+ln])) - } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go deleted file mode 100644 index 6fad0d8..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "time" -) - -type req struct{ ID int `json:"id"`; Method string `json:"method"` } -type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` } - -func main() { - in := bufio.NewReader(os.Stdin) - out := bufio.NewWriter(os.Stdout) - enc, dec := json.NewEncoder(out), json.NewDecoder(in) - - const N = 20000 - t0 := time.Now() - for i := 0; i < N; i++ { - enc.Encode(req{ID: i, Method: "stage.lock"}) - out.Flush() - var r resp - if err := dec.Decode(&r); err != nil { fmt.Fprintln(os.Stderr, "dec:", err); return } - } - d := time.Since(t0) - fmt.Fprintf(os.Stderr, "CHILD: %d 次 lock RPC 往返 用时 %v, 均摊 %.2f µs/次\n", - N, d, float64(d.Microseconds())/float64(N)) -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp3_parent.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp3_parent.go deleted file mode 100644 index 5e7d88d..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp3_parent.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "os/exec" - "sync" -) - -type req struct{ ID int `json:"id"`; Method string `json:"method"` } -type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` } - -func main() { - fmt.Println("=== 实验 3:锁仲裁 RPC 往返成本(stdio JSON-RPC)===") - cmd := exec.Command("go", "run", "exp3_child.go") - stdin, _ := cmd.StdinPipe() - stdout, _ := cmd.StdoutPipe() - cmd.Stderr = os.Stderr - cmd.Start() - - var mu sync.Mutex // 内核侧真实的锁仲裁 - dec := json.NewDecoder(bufio.NewReader(stdout)) - w := bufio.NewWriter(stdin) - enc := json.NewEncoder(w) - for { - var q req - if err := dec.Decode(&q); err != nil { break } - mu.Lock() // 真实加锁 - mu.Unlock() // 立即释放(模拟仲裁开销) - enc.Encode(resp{ID: q.ID, OK: true}) - w.Flush() - } - cmd.Wait() -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp4.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp4.go deleted file mode 100644 index acc7143..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp4.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build ignore -package main - -import ( - "fmt" - "os" - "sync/atomic" - "time" - - "golang.org/x/sys/unix" -) - -type ring struct { - writeSeq atomic.Uint64 - cap uint64 - slots []uint64 -} - -func main() { - fmt.Println("=== 实验 4:事件环 post-and-forget vs 同步 Publish(慢消费者场景)===") - const tokens = 5000 - - // --- A. 现状:同步 Publish,消费者慢 --- - slowHandler := func() { time.Sleep(20 * time.Microsecond) } - t0 := time.Now() - for i := 0; i < tokens; i++ { slowHandler() } - syncDur := time.Since(t0) - fmt.Printf("A 同步 Publish (慢消费者 20µs): %d token 耗时 %v → 均摊 %.1f µs/token\n", - tokens, syncDur, float64(syncDur.Microseconds())/tokens) - - // --- B. 新方案:写环 + eventfd post,不等消费者 --- - r := &ring{cap: 1024, slots: make([]uint64, 1024)} - efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK) - f := os.NewFile(uintptr(efd), "e") - - var dropped atomic.Uint64 - // 慢消费者 goroutine - done := make(chan struct{}) - go func() { - buf := make([]byte, 8) - var readSeq uint64 - for { - if _, err := f.Read(buf); err != nil { return } - w := r.writeSeq.Load() - if w-readSeq > r.cap { - dropped.Add(w - readSeq - r.cap) - readSeq = w - r.cap - } - for readSeq < w { readSeq++ } - time.Sleep(20 * time.Microsecond) // 慢 - select { case <-done: return; default: } - } - }() - - t0 = time.Now() - one := []byte{1,0,0,0,0,0,0,0} - for i := 0; i < tokens; i++ { - s := r.writeSeq.Add(1) - r.slots[s%r.cap] = s // 写数据 - f.Write(one) // post,不等 - } - asyncDur := time.Since(t0) - close(done) - fmt.Printf("B 环+eventfd post: %d token 耗时 %v → 均摊 %.2f µs/token\n", - tokens, asyncDur, float64(asyncDur.Microseconds())/tokens) - fmt.Printf("\n加速比 %.1fx 丢弃事件 %d(消费者跟不上,已计数)\n", - float64(syncDur)/float64(asyncDur), dropped.Load()) - if asyncDur < syncDur/5 { - fmt.Println("✅ post-and-forget 使流式发布与消费者速度解耦") - } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp5.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp5.go deleted file mode 100644 index 332b7d2..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp5.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build ignore -package main - -import ( - "fmt" - "os" - "os/exec" - "strconv" - "strings" - "time" -) - -func pssKB(pid int) int { - b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid)) - if err != nil { return 0 } - for _, l := range strings.Split(string(b), "\n") { - if strings.HasPrefix(l, "Pss:") { - f := strings.Fields(l) - n, _ := strconv.Atoi(f[1]); return n - } - } - return 0 -} -func threads(pid int) int { - e, _ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e) -} - -func main() { - fmt.Println("=== 实验 5:17 个 Go 子进程插件的真实常驻开销(PSS 计入共享页去重)===") - var cmds []*exec.Cmd - for i := 0; i < 17; i++ { - c := exec.Command("./plugbin") - c.Stdin, _ = os.Open(os.DevNull) - if err := c.Start(); err != nil { fmt.Println("start:", err); return } - cmds = append(cmds, c) - } - time.Sleep(1500 * time.Millisecond) - - totalPss, totalThreads := 0, 0 - for _, c := range cmds { - totalPss += pssKB(c.Process.Pid) - totalThreads += threads(c.Process.Pid) - } - fmt.Printf("17 进程合计: PSS = %.1f MB, 线程 = %d\n", float64(totalPss)/1024, totalThreads) - fmt.Printf("单进程均摊: PSS = %.2f MB, 线程 = %.1f\n", - float64(totalPss)/1024/17, float64(totalThreads)/17) - fmt.Printf("\n对照 homed 当前(单进程装 17 个 .so):\n") - // 找 homed - out, _ := exec.Command("pgrep", "-x", "homed").Output() - if p := strings.TrimSpace(string(out)); p != "" { - pid, _ := strconv.Atoi(strings.Fields(p)[0]) - fmt.Printf(" homed PSS = %.1f MB, 线程 = %d\n", float64(pssKB(pid))/1024, threads(pid)) - } - for _, c := range cmds { c.Process.Kill(); c.Wait() } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp5_plugin.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp5_plugin.go deleted file mode 100644 index 28e294f..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp5_plugin.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "os" -) - -// 模拟一个最小插件:stdio JSON-RPC loop + 一个 goroutine -func main() { - go func() { select {} }() - in := bufio.NewReader(os.Stdin) - dec := json.NewDecoder(in) - out := bufio.NewWriter(os.Stdout) - enc := json.NewEncoder(out) - for { - var m map[string]interface{} - if err := dec.Decode(&m); err != nil { return } - enc.Encode(map[string]interface{}{"ok": true}) - out.Flush() - } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go deleted file mode 100644 index e974e93..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build ignore -package main - -import ( - "fmt" - "os" - "os/exec" - "strconv" - "strings" - "time" -) - -func pssKB(pid int) int { - b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid)) - if err != nil { return -1 } - for _, l := range strings.Split(string(b), "\n") { - if strings.HasPrefix(l, "Pss:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n } - } - return -1 -} -func rssKB(pid int) int { - b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) - if err != nil { return -1 } - for _, l := range strings.Split(string(b), "\n") { - if strings.HasPrefix(l, "VmRSS:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n } - } - return -1 -} -func threads(pid int) int { e,_ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e) } - -func main() { - fmt.Println("=== 实验 5b:17 个 Go 子进程常驻开销(保持 stdin 管道存活)===") - var cmds []*exec.Cmd - var pipes []interface{ Close() error } - for i := 0; i < 17; i++ { - c := exec.Command("./plugbin") - w, _ := c.StdinPipe() // 保持打开 → 不 EOF - pipes = append(pipes, w) - c.Stdout = nil - if err := c.Start(); err != nil { fmt.Println(err); return } - cmds = append(cmds, c) - } - time.Sleep(2 * time.Second) - - tp, tr, tt, alive := 0, 0, 0, 0 - for _, c := range cmds { - pid := c.Process.Pid - if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); err != nil { continue } - alive++ - if v := pssKB(pid); v > 0 { tp += v } - if v := rssKB(pid); v > 0 { tr += v } - tt += threads(pid) - } - fmt.Printf("存活进程 %d/17\n", alive) - fmt.Printf("合计: PSS=%.1f MB RSS=%.1f MB 线程=%d\n", - float64(tp)/1024, float64(tr)/1024, tt) - if alive > 0 { - fmt.Printf("均摊: PSS=%.2f MB RSS=%.2f MB 线程=%.1f\n", - float64(tp)/1024/float64(alive), float64(tr)/1024/float64(alive), float64(tt)/float64(alive)) - } - out, _ := exec.Command("pgrep", "-x", "homed").Output() - if p := strings.TrimSpace(string(out)); p != "" { - pid, _ := strconv.Atoi(strings.Fields(p)[0]) - fmt.Printf("\n对照 homed(单进程 + 17 个 .so): RSS=%.1f MB 线程=%d\n", - float64(rssKB(pid))/1024, threads(pid)) - } - for _, c := range cmds { c.Process.Kill(); c.Wait() } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp6.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp6.go deleted file mode 100644 index e8540d6..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp6.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "time" -) - -func main() { - fmt.Println("=== 实验 6:子进程崩溃隔离 + 退出码/EOF 作为 recordCrash 信号 ===") - cmd := exec.Command("./crashbin") - sin, _ := cmd.StdinPipe() - sout, _ := cmd.StdoutPipe() - cmd.Stderr = nil // 丢弃 panic 栈 - cmd.Start() - fmt.Printf("插件进程 pid=%d 已启动\n", cmd.Process.Pid) - - enc := json.NewEncoder(sin) - dec := json.NewDecoder(bufio.NewReader(sout)) - - // 正常调用 - enc.Encode(map[string]string{"method": "ping"}) - var r map[string]interface{} - if err := dec.Decode(&r); err == nil { fmt.Println("正常调用 → ", r) } - - // 触发崩溃 - fmt.Println("\n发送 boom(插件内 panic)...") - t0 := time.Now() - enc.Encode(map[string]string{"method": "boom"}) - err := dec.Decode(&r) - - detected := "未检测到" - if errors.Is(err, io.EOF) || err == io.ErrUnexpectedEOF { detected = "EOF" } else if err != nil { detected = fmt.Sprintf("%v", err) } - fmt.Printf("调用侧感知: %s (耗时 %v)\n", detected, time.Since(t0)) - - werr := cmd.Wait() - var ec int = -1 - if ee, ok := werr.(*exec.ExitError); ok { ec = ee.ExitCode() } - fmt.Printf("进程退出码 = %d (panic → 2,可直接喂 recordCrash)\n", ec) - - fmt.Printf("\n宿主进程仍存活: pid=%d ✅ 崩溃已隔离\n", os.Getpid()) - fmt.Println("→ 对照:当前 .so 模型下,bridge 兜不住的 panic 会带崩整个 homed") -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go deleted file mode 100644 index dbf7ed6..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "os" -) - -func main() { - dec := json.NewDecoder(bufio.NewReader(os.Stdin)) - out := bufio.NewWriter(os.Stdout) - enc := json.NewEncoder(out) - for { - var m map[string]interface{} - if err := dec.Decode(&m); err != nil { return } - if m["method"] == "boom" { - panic("插件故意崩溃") // 真 panic - } - enc.Encode(map[string]interface{}{"ok": true}) - out.Flush() - } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp7.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp7.go deleted file mode 100644 index c25f84f..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp7.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "os/exec" - "time" -) - -func spawnAndAsk(bin string) string { - cmd := exec.Command(bin) - sin, _ := cmd.StdinPipe() - sout, _ := cmd.StdoutPipe() - cmd.Start() - enc := json.NewEncoder(sin) - dec := json.NewDecoder(bufio.NewReader(sout)) - enc.Encode(map[string]string{"method": "version"}) - var r map[string]interface{} - dec.Decode(&r) - sin.Close() - cmd.Process.Kill() - cmd.Wait() - if v, ok := r["version"].(string); ok { return v } - return "?" -} - -func build(ver, out string) { - src := fmt.Sprintf(`package main -import ("bufio";"encoding/json";"os") -func main(){ - dec:=json.NewDecoder(bufio.NewReader(os.Stdin)) - w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w) - for { var m map[string]interface{} - if err:=dec.Decode(&m); err!=nil {return} - enc.Encode(map[string]string{"version":%q}); w.Flush() } -}`, ver) - os.MkdirAll("v", 0755) - os.WriteFile("v/main.go", []byte(src), 0644) - os.WriteFile("v/go.mod", []byte("module v\ngo 1.21\n"), 0644) - c := exec.Command("go", "build", "-o", "../"+out, ".") - c.Dir = "v" - if b, err := c.CombinedOutput(); err != nil { fmt.Println("build err:", string(b)) } -} - -func main() { - fmt.Println("=== 实验 7:子进程模型下的热重载(迁移的原始目标)===") - build("v1.0.0", "hotbin") - fmt.Printf("1) 首次启动插件 → version = %s\n", spawnAndAsk("./hotbin")) - - fmt.Println("2) 替换二进制为 v2.0.0(同路径,无需版本化 hash 目录)") - build("v2.0.0", "hotbin") - time.Sleep(200 * time.Millisecond) - - v := spawnAndAsk("./hotbin") - fmt.Printf("3) 重启插件进程 → version = %s\n", v) - if v == "v2.0.0" { - fmt.Println("\n✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏") - fmt.Println(" 对照 .so 模型:同路径 dlopen 复用旧映像,永远拿不到 v2") - } -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp8.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp8.go deleted file mode 100644 index a74d5eb..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp8.go +++ /dev/null @@ -1,84 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/binary" - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - "sync" - "time" - - "golang.org/x/sys/unix" -) - -func main() { - fmt.Println("=== 实验 8:跨进程并发扇出改写同一 StageContext(最高风险点 3.4)===") - - mfd, _ := unix.MemfdCreate("stagectx", 0) - unix.Ftruncate(mfd, 65536) - shmFile := os.NewFile(uintptr(mfd), "shm") - data, _ := unix.Mmap(mfd, 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) - - // 初始 final_text = "" @1024, arena 游标 = 1024 - binary.LittleEndian.PutUint32(data[0:4], 1024) - binary.LittleEndian.PutUint32(data[4:8], 0) - binary.LittleEndian.PutUint32(data[8:12], 1024) - - tags := []string{"A", "B", "C", "D", "E"} // 5 个并发插件 - var mu sync.Mutex // 内核侧锁仲裁 - var wg sync.WaitGroup - var rpcCount int64 - var cntMu sync.Mutex - - t0 := time.Now() - for _, tag := range tags { - cmd := exec.Command("go", "run", "exp8_worker.go", tag) - cmd.ExtraFiles = []*os.File{shmFile} - sin, _ := cmd.StdinPipe() - sout, _ := cmd.StdoutPipe() - cmd.Stderr = os.Stderr - cmd.Start() - wg.Add(1) - go func() { - defer wg.Done() - dec := json.NewDecoder(bufio.NewReader(sout)) - w := bufio.NewWriter(sin) - enc := json.NewEncoder(w) - held := false - for { - var q map[string]string - if err := dec.Decode(&q); err != nil { break } - switch q["method"] { - case "stage.lock": mu.Lock(); held = true - case "stage.unlock": if held { mu.Unlock(); held = false } - } - cntMu.Lock(); rpcCount++; cntMu.Unlock() - enc.Encode(map[string]bool{"ok": true}); w.Flush() - } - if held { mu.Unlock() } - cmd.Wait() - }() - } - wg.Wait() - dur := time.Since(t0) - - off := binary.LittleEndian.Uint32(data[0:4]) - ln := binary.LittleEndian.Uint32(data[4:8]) - final := string(data[off : off+ln]) - - fmt.Printf("\n--- 结果 ---\n") - fmt.Printf("最终 final_text 长度 = %d\n", len(final)) - counts := map[string]int{} - for _, t := range tags { counts[t] = strings.Count(final, t) } - fmt.Printf("各插件写入次数: %v\n", counts) - total := 0 - for _, c := range counts { total += c } - fmt.Printf("总字符 = %d, 长度 = %d → %s\n", total, len(final), - map[bool]string{true:"一致 ✅ 无丢失/无撕裂", false:"不一致 ❌"}[total == len(final)]) - fmt.Printf("RPC 锁操作 = %d 次, 总耗时 %v\n", rpcCount, dur) - fmt.Printf("\n注:写入次数少于 5×300 是 arena 64KB 上限所致(append-only 未压实),符合设计\n") -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp8_worker.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp8_worker.go deleted file mode 100644 index 336a75a..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp8_worker.go +++ /dev/null @@ -1,50 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/binary" - "encoding/json" - "fmt" - "os" - "strconv" - - "golang.org/x/sys/unix" -) - -// 模拟插件:拿锁 → 读 final_text → 追加自己的标记 → 写回 → 放锁 -// 锁通过 stdio RPC 向内核申请(方案 3.7:锁仲裁回归内核,无 cgo) -func main() { - tag := os.Args[1] - shmf := os.NewFile(3, "shm") - data, err := unix.Mmap(int(shmf.Fd()), 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) - if err != nil { fmt.Fprintln(os.Stderr, "mmap:", err); os.Exit(1) } - - dec := json.NewDecoder(bufio.NewReader(os.Stdin)) - w := bufio.NewWriter(os.Stdout) - enc := json.NewEncoder(w) - rpc := func(method string) { - enc.Encode(map[string]string{"method": method}); w.Flush() - var r map[string]interface{}; dec.Decode(&r) - } - - const iters = 300 - for i := 0; i < iters; i++ { - rpc("stage.lock") - // --- 临界区:偏移解引用读写 final_text --- - off := binary.LittleEndian.Uint32(data[0:4]) - ln := binary.LittleEndian.Uint32(data[4:8]) - cur := string(data[off : off+ln]) - add := tag - newS := cur + add - // append-only arena:写到新位置 - newOff := binary.LittleEndian.Uint32(data[8:12]) - if int(newOff)+len(newS) > 65536 { rpc("stage.unlock"); break } - copy(data[newOff:], []byte(newS)) - binary.LittleEndian.PutUint32(data[0:4], newOff) - binary.LittleEndian.PutUint32(data[4:8], uint32(len(newS))) - binary.LittleEndian.PutUint32(data[8:12], newOff+uint32(len(newS))) - rpc("stage.unlock") - } - fmt.Fprintln(os.Stderr, "worker "+tag+" done, iters="+strconv.Itoa(iters)) -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp9.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp9.go deleted file mode 100644 index 99a852c..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp9.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build ignore -package main - -import ( - "bufio" - "encoding/json" - "fmt" - "os/exec" - "sync" - "time" -) - -func run(name, arg string, mu *sync.Mutex, crashed *bool) { - cmd := exec.Command("go", "run", "exp9_worker.go", arg) - sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe() - cmd.Stderr = nil - cmd.Start() - dec := json.NewDecoder(bufio.NewReader(sout)) - w := bufio.NewWriter(sin); enc := json.NewEncoder(w) - held := false - for { - var q map[string]string - if err := dec.Decode(&q); err != nil { break } - switch q["method"] { - case "stage.lock": mu.Lock(); held = true; fmt.Printf(" [%s] 获得锁\n", name) - case "stage.unlock": if held { mu.Unlock(); held = false; fmt.Printf(" [%s] 释放锁\n", name) } - } - enc.Encode(map[string]bool{"ok":true}); w.Flush() - } - err := cmd.Wait() - // 关键:进程死了,内核侧检测到 EOF/退出 → 强制释放它持有的锁 - if held { - mu.Unlock() - *crashed = true - fmt.Printf(" [%s] 进程死亡(%v),内核强制释放其持有的锁 ← 自愈\n", name, err) - } -} - -func main() { - fmt.Println("=== 实验 9:持锁进程崩溃后的自愈(验证无需 robust pthread_mutex)===") - var mu sync.Mutex - crashed := false - - fmt.Println("\n1) 插件 X 拿锁后 panic:") - run("X", "crash", &mu, &crashed) - - fmt.Println("\n2) 插件 Y 随后申请同一把锁:") - done := make(chan bool, 1) - go func() { run("Y", "normal", &mu, new(bool)); done <- true }() - select { - case <-done: - fmt.Println("\n✅ Y 正常获得并释放锁 —— 无死锁") - fmt.Println(" → 内核持有锁的所有权,进程死亡由 Wait()/EOF 检测并强制释放") - fmt.Println(" → 不需要 PTHREAD_PROCESS_SHARED|ROBUST,也不需要处理 EOWNERDEAD") - fmt.Println(" → 整个架构可做到零 cgo") - case <-time.After(15 * time.Second): - fmt.Println("\n❌ 死锁:Y 拿不到锁(说明需要 robust 语义)") - } - _ = crashed -} diff --git a/docs/zh/experiments/plugin-arch/02-feasibility/exp9_worker.go b/docs/zh/experiments/plugin-arch/02-feasibility/exp9_worker.go deleted file mode 100644 index 5794a1e..0000000 --- a/docs/zh/experiments/plugin-arch/02-feasibility/exp9_worker.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build ignore -package main - -import ("bufio";"encoding/json";"os") -func main() { - dec := json.NewDecoder(bufio.NewReader(os.Stdin)) - w := bufio.NewWriter(os.Stdout); enc := json.NewEncoder(w) - rpc := func(m string) { enc.Encode(map[string]string{"method":m}); w.Flush(); var r map[string]interface{}; dec.Decode(&r) } - rpc("stage.lock") - if os.Args[1] == "crash" { panic("持锁时崩溃") } // 拿着锁死掉 - rpc("stage.unlock") -} diff --git a/docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go b/docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go deleted file mode 100644 index 3e9cc45..0000000 --- a/docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build ignore - -package main - -import ( - "encoding/json" - "fmt" - "strings" - "sync" -) - -// 完全复刻内核 loader.go case 2 + templates.go go_invoke_stage 的链路 -type StageCtx struct { - mu sync.RWMutex - LLMText string - ToolRes []string -} - -func (c *StageCtx) Lock() { c.mu.Lock() } -func (c *StageCtx) Unlock() { c.mu.Unlock() } -func (c *StageCtx) RLock() { c.mu.RLock() } -func (c *StageCtx) RUnlock() { c.mu.RUnlock() } - -// === 模拟外部插件(副本模型)=== -func externalPlugin(tag string, ctxJSON string) string { - // go_invoke_stage: 新建全新对象 - sc := &StageCtx{} - var m map[string]interface{} - json.Unmarshal([]byte(ctxJSON), &m) - if v, ok := m["llm_text"].(string); ok { sc.LLMText = v } - - // 插件 handler:ctx.Lock() 锁的是这个新对象 → 空转 - sc.Lock() - sc.LLMText = sc.LLMText + "[" + tag + "]" - sc.Unlock() - - out, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText}) - return string(out) -} - -// === 模拟内核 case 2 handler === -func kernelStageHandler(sc *StageCtx, tag string) { - sc.RLock() - snap, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText}) - sc.RUnlock() - - result := externalPlugin(tag, string(snap)) - - // applyStageResult - var m map[string]interface{} - json.Unmarshal([]byte(result), &m) - sc.Lock() - if v, ok := m["llm_text"].(string); ok { sc.LLMText = v } - sc.Unlock() -} - -// === 内置插件:直接改同一对象 === -func nativePlugin(sc *StageCtx, tag string) { - sc.Lock() - sc.LLMText = sc.LLMText + "[" + tag + "]" - sc.Unlock() -} - -func runCase(name string, fn func(*StageCtx, string), tags []string, rounds int) { - lost := 0 - for r := 0; r < rounds; r++ { - sc := &StageCtx{LLMText: "BASE"} - var wg sync.WaitGroup - for _, t := range tags { - wg.Add(1) - go func(t string) { defer wg.Done(); fn(sc, t) }(t) - } - wg.Wait() - // 检查是否所有 tag 都在 - for _, t := range tags { - if !strings.Contains(sc.LLMText, "["+t+"]") { lost++; break } - } - } - fmt.Printf(" %-28s %d/%d 轮出现修改丢失 (%.1f%%)\n", name, lost, rounds, float64(lost)/float64(rounds)*100) -} - -func main() { - tags := []string{"A", "B", "C", "D", "E"} - fmt.Println("5 个插件并发在 StageBeforeToolcall 追加标记,各 2000 轮:") - fmt.Println() - runCase("内置插件(共享同一对象)", nativePlugin, tags, 2000) - runCase("外部插件(快照-副本-写回)", kernelStageHandler, tags, 2000) - fmt.Println() - fmt.Println("→ 副本模型下 read-modify-write 非原子:快照与写回之间的窗口导致覆盖") -} diff --git a/docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go b/docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go deleted file mode 100644 index aecc13c..0000000 --- a/docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go +++ /dev/null @@ -1,101 +0,0 @@ -//go:build ignore - -package main - -// 精确复刻现网 AfterToolcall 上 sanitizer(Global,改写) + weather(OwnTools,只读) 的并发 -import ( - "encoding/json" - "fmt" - "strings" - "sync" -) - -type ToolResult struct { - Name string `json:"name"` - Plugin string `json:"plugin"` - Result interface{} `json:"result"` -} -type Ctx struct { - mu sync.RWMutex - ToolRes []ToolResult -} -func (c *Ctx) Lock(){c.mu.Lock()}; func (c *Ctx) Unlock(){c.mu.Unlock()} -func (c *Ctx) RLock(){c.mu.RLock()}; func (c *Ctx) RUnlock(){c.mu.RUnlock()} - -func cleanText(s string) string { - // 模拟 sanitizer:去掉 ANSI/坏字节 - return strings.ReplaceAll(s, "\x1b[31m", "") -} - -// 内核 case 2 handler(外部插件通用路径) -func kernelExternal(sc *Ctx, pluginFn func(*Ctx)) { - // 1. 快照 - sc.RLock() - snap, _ := json.Marshal(map[string]interface{}{"tool_results": sc.ToolRes}) - sc.RUnlock() - - // 2. go_invoke_stage: 插件进程内全新对象 - local := &Ctx{} - var m map[string]interface{} - json.Unmarshal(snap, &m) - if v, ok := m["tool_results"]; ok { - b, _ := json.Marshal(v) - json.Unmarshal(b, &local.ToolRes) - } - - // 3. 插件 handler 跑在副本上 - pluginFn(local) - - // 4. stageContextWritable: 无条件回传 tool_results - out := map[string]interface{}{} - if len(local.ToolRes) > 0 { out["tool_results"] = local.ToolRes } - rb, _ := json.Marshal(out) - - // 5. applyStageResult 写回内核 - var rm map[string]interface{} - json.Unmarshal(rb, &rm) - sc.Lock() - if v, ok := rm["tool_results"]; ok { - b, _ := json.Marshal(v) - var trs []ToolResult - if json.Unmarshal(b, &trs) == nil { sc.ToolRes = trs } - } - sc.Unlock() -} - -func sanitizerStage(ctx *Ctx) { - ctx.Lock(); defer ctx.Unlock() - for i, tr := range ctx.ToolRes { - if s, ok := tr.Result.(string); ok { - ctx.ToolRes[i].Result = cleanText(s) - } - } -} -func weatherStage(ctx *Ctx) { - ctx.Lock(); defer ctx.Unlock() - // 只读打印,不改(own_tools scope 已匹配) - _ = len(ctx.ToolRes) -} - -func main() { - const rounds = 3000 - dirty := "\x1b[31m晴 25°C" - polluted := 0 - for r := 0; r < rounds; r++ { - sc := &Ctx{ToolRes: []ToolResult{{Name:"weather_query", Plugin:"weather", Result: dirty}}} - var wg sync.WaitGroup - wg.Add(2) - go func(){ defer wg.Done(); kernelExternal(sc, sanitizerStage) }() - go func(){ defer wg.Done(); kernelExternal(sc, weatherStage) }() - wg.Wait() - if s, ok := sc.ToolRes[0].Result.(string); ok && strings.Contains(s, "\x1b[31m") { - polluted++ - } - } - fmt.Printf("现网场景复刻:模型调用 weather_query,sanitizer+weather 并发跑 AfterToolcall\n") - fmt.Printf(" %d 轮中 %d 轮清洗结果被覆盖 (%.1f%%)\n", rounds, polluted, float64(polluted)/rounds*100) - if polluted > 0 { - fmt.Printf("\n ⚠️ 确认:weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果\n") - fmt.Printf(" → 脏数据(ANSI 转义)进入 LLM 上下文\n") - } -} diff --git a/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14a/main.go b/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14a/main.go deleted file mode 100644 index df399df..0000000 --- a/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14a/main.go +++ /dev/null @@ -1,62 +0,0 @@ -//go:build ignore - -package main - -/* -#cgo LDFLAGS: -ldl -#include -#include -typedef void (*fn)(void); -static void call(void* f){ ((fn)f)(); } -*/ -import "C" -import ( - "fmt" - "os" - "os/exec" - "runtime" - "time" - "unsafe" -) - -func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) } - -func main() { - fmt.Println("=== A. cgo 模型:插件死循环,超时后能回收吗? ===") - p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p)) - n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n)) - - base := threads() - fmt.Printf(" 基线: goroutines=%d threads=%d\n", runtime.NumGoroutine(), base) - - for i := 1; i <= 3; i++ { - done := make(chan string, 1) - go func() { C.call(f); done <- "ok" }() // 模拟 executeToolCallInner - select { - case <-done: - case <-time.After(600 * time.Millisecond): // 缩短的"60s 超时" - } - time.Sleep(200 * time.Millisecond) - fmt.Printf(" 第 %d 次超时后: goroutines=%d threads=%d (+%d)\n", - i, runtime.NumGoroutine(), threads(), threads()-base) - } - fmt.Println(" ❌ 每次超时永久泄漏 1 goroutine + 1 OS 线程(cgo 调用不可中断)") - - fmt.Println("\n=== B. 子进程模型:同样死循环,可强杀 ===") - base2 := threads() - for i := 1; i <= 3; i++ { - cmd := exec.Command("sleep", "3600") - cmd.Start() - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case <-done: - case <-time.After(300 * time.Millisecond): - cmd.Process.Kill() // ← 可强制终止 - <-done - } - fmt.Printf(" 第 %d 次超时+Kill 后: goroutines=%d threads=%d (+%d)\n", - i, runtime.NumGoroutine(), threads(), threads()-base2) - } - fmt.Println(" ✅ 零泄漏:进程被杀,OS 回收全部资源") -} diff --git a/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14b/main.go b/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14b/main.go deleted file mode 100644 index 0e0f79e..0000000 --- a/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14b/main.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build ignore - -package main - -/* -#cgo LDFLAGS: -ldl -#include -#include -typedef void (*fn)(void); -static void call(void* f){ ((fn)f)(); } -*/ -import "C" -import ( - "fmt" - "os" - "runtime" - "time" - "unsafe" -) - -func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) } - -func main() { - p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p)) - n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n)) - base := threads() - fmt.Printf("基线 threads=%d goroutines=%d\n\n", base, runtime.NumGoroutine()) - for i := 1; i <= 20; i++ { - done := make(chan string, 1) - go func() { C.call(f); done <- "ok" }() - select { - case <-done: - case <-time.After(120 * time.Millisecond): - } - if i%5 == 0 { - fmt.Printf(" %2d 次卡死调用后: goroutines=%2d threads=%2d (+%d)\n", - i, runtime.NumGoroutine(), threads(), threads()-base) - } - } - fmt.Printf("\n结论: 20 次超时 → 泄漏 %d goroutine, %d OS 线程\n", - runtime.NumGoroutine()-1, threads()-base) - fmt.Println("每个卡在 cgo 里的 goroutine 独占一个 M(OS 线程),无法被抢占或回收") -} diff --git a/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/hang.c b/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/hang.c deleted file mode 100644 index 14375d2..0000000 --- a/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/hang.c +++ /dev/null @@ -1,2 +0,0 @@ -#include -void hang_forever(void) { while(1) sleep(1); } diff --git a/docs/zh/experiments/plugin-arch/19-migration-verify/README.md b/docs/zh/experiments/plugin-arch/19-migration-verify/README.md deleted file mode 100644 index bfa29c6..0000000 --- a/docs/zh/experiments/plugin-arch/19-migration-verify/README.md +++ /dev/null @@ -1,137 +0,0 @@ -# 实验 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 不强制先跑重编脚本。 - -## 压测与延迟(Part 6.6 验收) - -基准与压测在代码里而非独立脚本: -`internal/plugin/proc/bench_test.go` + `streaming_test.go`。 - -```bash -go test -run '^$' -bench . ./internal/plugin/proc/ -go test -run 'TestStreaming_' -v ./internal/plugin/proc/ -``` - -### 实测(2026-09-02,AMD Ryzen 7 7840HS) - -| 项目 | 实测 | 基线 | 判断 | -|---|---|---|---| -| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 | -| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 | -| 事件环写入 | 95 ns | — | 亚微秒 | -| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 | -| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 | -| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% | - -**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——两者测的不是同一个东西: -实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧 -`lockRegistry.acquire/release`。真实成本仍在 20µs 量级(那部分是 RPC 往返)。 -基准原名 `BenchmarkStageLockRoundTrip` 有误导性,已改为 -`BenchmarkStageLockArbitration`。 - -**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs(2.8%), -其余是**一次 stage 要走 3 次进程间往返**——`stage.invoke` 加上插件侧反向的 -`stage.lock` / `stage.unlock`。相对 LLM 往返 2-8 秒可忽略;若日后要优化, -方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答,省掉两次往返。 - -### 流式压测(§4.3 标记「风险高」的那一项) - -原文的担忧:「`Bus.Publish` 路径禁用任何锁/阻塞——流式输出逐 token 发布, -任何等待都会卡顿」。 - -``` -5000 次 Publish + 每条睡 20µs 的慢消费者 - 实测 2.29ms,均摊 457 ns/token - 同步语义理论下限 100ms(5000 × 20µs) - -订阅者 1 个:1.547ms(515 ns/次) -订阅者 8 个:1.518ms(506 ns/次) ← 几乎不变,无线性恶化 - -环溢出(无消费者写 30000 次,cap=8192):均摊 35 ns/次 ← 仍 O(1) -``` - -2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token)—— -post-and-forget 在实现中成立。 - -最后一项的意义:消费者完全停摆时写端覆盖最旧 slot,这条路径仍是 O(1), -故「消费者卡住」不会连带拖慢内核主循环。 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 deleted file mode 100755 index d9254f2..0000000 --- a/docs/zh/experiments/plugin-arch/19-migration-verify/measure-plugin-overhead.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/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 deleted file mode 100755 index ea37160..0000000 --- a/docs/zh/experiments/plugin-arch/19-migration-verify/rebuild-plugins.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/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/docs/zh/experiments/plugin-arch/19-migration-verify/switch-production.py b/docs/zh/experiments/plugin-arch/19-migration-verify/switch-production.py deleted file mode 100755 index 3faecc7..0000000 --- a/docs/zh/experiments/plugin-arch/19-migration-verify/switch-production.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -"""生产切换:经 pluginmgr 正规通道安装 17 个 hmap(Part 6.5)。 - -与手工拷贝方案的区别 —— 这里复用内核自己的安装逻辑: - - validatePackage 校验 manifest + 平台二进制齐全 - StopAndUnload 停旧实例但**保留配置表** - os.Rename 备份 解包失败自动回滚到旧版本 - platformBinary() 按 runtime 挑当前平台那份,重命名为 plugin.bin - chmod 0755 补执行位 - -手工拷贝会重新实现这一套,且必然实现得更差(第一版就漏了 platforms 字段 -与配置保留语义)。 - -用法: - switch-production.py 演练 - switch-production.py --apply 实际安装 -""" - -import json -import os -import sys -import urllib.error -import urllib.request - -PROD_PLUGINS = "/home/newqqagent/plugins" -SDK_EXAMPLE = "/home/program/TrueAgent/third_party/homeagent-sdk/example" -PLUGINMGR = "http://127.0.0.1:9876/plugins" - - -def find_hmap(name): - """找插件的 hmap 包。 - - bundle:true -> _bundle.hmap(含多平台二进制) - bundle:false -> __.hmap(qq 是这种) - """ - dist = os.path.join(SDK_EXAMPLE, name, "dist") - if not os.path.isdir(dist): - return None - cands = [f for f in os.listdir(dist) if f.endswith(".hmap")] - if not cands: - return None - for c in cands: - if c.endswith("_bundle.hmap"): - return os.path.join(dist, c) - return os.path.join(dist, sorted(cands)[0]) - - -def install(path): - """POST 到 pluginmgr。overwrite=true 走原地更新分支,保留配置表。""" - body = json.dumps({"path": path, "overwrite": True}).encode() - req = urllib.request.Request( - PLUGINMGR, data=body, - headers={"Content-Type": "application/json"}, - method="POST") - try: - with urllib.request.urlopen(req, timeout=180) as resp: - return json.loads(resp.read().decode()), None - except urllib.error.HTTPError as e: - return None, "HTTP %d: %s" % (e.code, e.read().decode()[:300]) - except Exception as e: - return None, str(e) - - -def main(): - apply = "--apply" in sys.argv - - targets = sorted( - d for d in os.listdir(PROD_PLUGINS) - if os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.so")) - or os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.bin")) - ) - print("生产外部插件: %d 个" % len(targets)) - - # 先全部校验,任一缺包就整批中止。 - # 理由:新 homed 不认 .so,「一半装了一半没装」的中间态最难排查。 - plan = [] - missing = [] - for name in targets: - h = find_hmap(name) - if h is None: - missing.append(name) - else: - plan.append((name, h)) - - if missing: - print("\n✗ 中止:以下插件缺 hmap 包:") - for m in missing: - print(" " + m) - print("\n先跑 rebuild-plugins.sh 重编。") - return 1 - - print("✓ 全部 %d 个 hmap 就位\n" % len(plan)) - for name, h in plan: - print(" %-16s %-44s %6d KB" % ( - name, os.path.basename(h), os.path.getsize(h) // 1024)) - - if not apply: - print("\n[演练] 加 --apply 才实际安装") - return 0 - - print("\n经 pluginmgr 安装(overwrite=true,保留配置)...") - ok = 0 - failed = [] - for name, h in plan: - result, err = install(h) - if err: - print(" ✗ %-16s %s" % (name, err)) - failed.append(name) - continue - if "error" in result: - print(" ✗ %-16s %s: %s" % ( - name, result["error"], result.get("details", ""))) - failed.append(name) - continue - print(" ✓ %-16s %-12s v%s -> v%s config_kept=%s" % ( - name, - result.get("action", "?"), - result.get("previous_version", "?"), - result.get("version", "?"), - result.get("config_kept", False))) - ok += 1 - - print("\n成功 %d / 失败 %d" % (ok, len(failed))) - if failed: - print("失败: " + " ".join(failed)) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docs/zh/experiments/plugin-arch/README.md b/docs/zh/experiments/plugin-arch/README.md deleted file mode 100644 index 4dfae51..0000000 --- a/docs/zh/experiments/plugin-arch/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# 插件架构评估实验 - -[`../../架构迁移评估.md`](../../架构迁移评估.md) 中所有数字的来源。 -**18 项实验,一键复跑**,用于复核结论或在改动后验证回归。 - -```bash -./run.sh # 跑全部(约 3-5 分钟) -./run.sh 12 13 # 只跑指定实验 -./run.sh 1 1c # dlclose/NODELETE 组 -``` - -依赖:`go >= 1.21`、`gcc`、Linux(用到 `eventfd`/`memfd_create`/`dlopen`)。 -脚本在 `mktemp -d` 里构建,**不污染主仓 `go.mod`**;实验源码均带 `//go:build ignore`。 - -拉取 `golang.org/x/sys` 需要网络(实验 1/2/4/8/10)。本机走 clash: -```bash -export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890 -``` - -## 目录 - -| 目录 | 主题 | 对应章节 | -|---|---|---| -| `01-dlclose-nodelete/` | `dlclose` 对 `DF_1_NODELETE` 是 no-op | 1.1 / 1.2 | -| `02-feasibility/` | 新架构可行性 11 项 | 第七章 | -| `03-lost-update/` | 副本模型的 lost update | 8.4 / 8.6 | -| `04-cgo-uninterruptible/` | cgo 调用不可中断 | 9.3 | - -## 实验清单与最近一次实测结果 - -复跑于 2026-08-31,go1.25.12 linux/amd64,192.168.2.60(12 核)。 - -### 01 组:dlclose / NODELETE - -| # | 实验 | 结论 | -|---|---|---| -| 1a | Go 宿主经纯 C shim 加载/卸载第三层 `.so` | 纯 C 目标可卸载;Go c-shared 目标仍不可 | -| 1b | `/proc/self/maps` 段数验证 | 纯 C: 5→**0**(真卸载);Go c-shared: 5→**5** | -| 1c | 版本化路径 dlopen | handle 不同,`ver=v2` 生效(方案可行但泄漏,已否决) | - -**关键**:`DF_1_NODELETE` 属于**被卸载对象自身**的 ELF 属性, -与谁调用 `dlopen` 无关——套任何层数的 C 中间件都绕不过去。 - -### 02 组:新架构可行性 - -| # | 实验 | 最近结果 | -|---|---|---| -| 1 | eventfd 是否走 Go netpoller | 200 goroutine 阻塞 → 线程 **+0~1** ✅ | -| 2 | 跨进程 eventfd + 偏移解引用 | 父子 mmap 基址不同,偏移仍正确;post **10.9 µs** | -| 3 | 锁仲裁 RPC 往返成本 | **19.4 µs/次**(20000 次) | -| 4 | post-and-forget vs 同步 Publish | 5.07s → 2.29ms(**2218x**) | -| 5 | 17 子进程常驻开销 | **29.1MB RSS / 12.9MB PSS**,84 线程 | -| 6 | 子进程崩溃隔离 | 退出码 **2**,EOF **2.5ms** 感知,宿主存活 | -| 7 | 子进程热重载 | 同路径替换二进制 → v1→v2 立即生效 | -| 8 | **跨进程并发改写 StageContext** | 5 进程 × 300 轮,**零丢失零撕裂** | -| 9 | 持锁进程崩溃自愈 | 无死锁,**无需 robust mutex** | -| 10 | 二进制零拷贝 | 100KB/1MB/5MB → **14-22x**,体积 −100% | -| 11 | 工具调用 RPC 延迟 | p50 **19.6 µs**,占 LLM 往返 0.00065% | - -### 03 组:副本模型缺陷 - -| # | 实验 | 最近结果 | -|---|---|---| -| 12 | 副本模型 lost update 率 | 内置 **0%** vs 外部 **35.8~36.8%** | -| 13 | 现网 sanitizer+weather 冲突 | **1.6~4.3%** 清洗结果被覆盖 | - -**实验 12 的对照设计是重点**:两组用**完全相同的并发扇出** -(`stages.go:124` 的 `go func` + `wg.Wait()`),唯一差异是 -「共享同一 `*StageContext`」vs「快照-副本-写回」。 - -内置组 0% 证明**并发扇出这个原始设计是正确的**; -副本组 36% 证明**跨 C ABI 边界后锁语义失效**才是缺陷所在。 -不要据此得出"应该取消并发"的结论。 - -⚠️ **13 的比率随机器负载波动**(观测区间 1.6%~4.3%)——它取决于两个插件 -handler 的实际执行耗时比。文档正文引用 1.6% 是首次测量值, -**应理解为「量级在百分之几」而非精确常数**。 - -### 04 组:cgo 不可中断 - -| # | 实验 | 最近结果 | -|---|---|---| -| 14a | cgo 死循环 vs 子进程 Kill | cgo 泄漏;子进程 **零泄漏** | -| 14b | 泄漏增长曲线(20 次) | 泄漏 **20 goroutine / 18 OS 线程**,线性 | - -## 复跑时的注意事项 - -**结果会有波动,以下属正常**: - -- 实验 12/13 的丢失率随调度波动(12 稳定在 35~37%,13 在 1.6~4.3%) -- 实验 1 的线程增长为 0 或 1(取决于 netpoller 线程是否已存在) -- 实验 10 的加速比 14~22x(受 CPU 缓存状态影响) -- 实验 5 的 PSS 受同机其他 Go 进程影响(共享页计算) - -**结果不应变的**(若变了说明环境或结论有问题): - -- 实验 1b 中纯 C `.so` 的段数必须归 **0**,Go c-shared 必须**不归零** -- 实验 8 的「总字符数 == 最终长度」必须成立(零丢失) -- 实验 9 必须无死锁 -- 实验 12 的内置模型必须 **0%** -- 实验 14b 的泄漏必须**线性增长** - -## 已知限制 - -- 实验 8 的 arena 未实现压实,64KB 用尽即停止写入(写入次数 < 5×300 属预期, - 见评估文档 3.3) -- 实验 12/13 是**链路复刻**而非直接调用生产代码, - 证明的是「副本模型这一机制」存在缺陷,不能替代对 `sanitizer`/`weather` - 的真实行为回归测试 -- 实验 5 的插件是最小 stdio loop(2.68MB),真实插件(如 qq 7.5MB)开销更高 -- 无 Windows 环境,9.2 的 Windows DLL 缺陷**未经实测**,仅代码阅读 diff --git a/docs/zh/experiments/plugin-arch/run.sh b/docs/zh/experiments/plugin-arch/run.sh deleted file mode 100755 index f9f687d..0000000 --- a/docs/zh/experiments/plugin-arch/run.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# 插件架构评估实验 —— 一键复跑 -# 用法: ./run.sh [实验编号...] 例: ./run.sh 12 13 留空跑全部 -# 依赖: go >= 1.21, gcc, Linux (eventfd/memfd/dlopen) -set -uo pipefail -cd "$(dirname "$0")" -ROOT=$(pwd) -PASS=0; FAIL=0 - -need() { command -v "$1" >/dev/null || { echo "缺少依赖: $1"; exit 1; }; } -need go; need gcc - -# 统一的临时 module 环境(避免污染主仓 go.mod) -WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT - -banner() { echo; echo "════════ $* ════════"; } - -# x/sys 只有 exp1/2/4/8/10 需要 -prep_xsys() { - cat > "$1/go.mod" </dev/null 2>&1) -} -prep_plain() { printf 'module exp\ngo 1.21\n' > "$1/go.mod"; } - -run_go() { # <目录> <说明> - if (cd "$1" && go run . 2>&1); then PASS=$((PASS+1)); else echo " ❌ 失败: $2"; FAIL=$((FAIL+1)); fi -} - -SEL="${*:-all}" -sel() { [ "$SEL" = "all" ] && return 0; case " $SEL " in *" $1 "*) return 0;; esac; return 1; } - -# ── 01: dlclose / NODELETE ──────────────────────────────── -if sel 1; then - banner "实验 1 组: dlclose 对 DF_1_NODELETE 是 no-op" - W=$WORK/e01; mkdir -p $W; cp 01-dlclose-nodelete/*.c $W/ - gcc -shared -fPIC -o $W/probe_v1.so $W/probe_v1.c - gcc -shared -fPIC -o $W/probe_v2.so $W/probe_v2.c - gcc -shared -fPIC -o $W/shim.so $W/shim.c - cp $W/probe_v1.so $W/probe.so - for e in exp01a exp01b; do - mkdir -p $W/$e; cp 01-dlclose-nodelete/$e/main.go $W/$e/ - sed -i '/^\/\/go:build ignore$/d' $W/$e/main.go; prep_plain $W/$e - (cd $W/$e && go build -o ../$e.bin . 2>&1 | head -3) - done - echo "--- 01a: Go 宿主经 C shim 加载/卸载纯 C so ---" - (cd $W && ./exp01a.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1)) - echo "--- 01b: /proc/self/maps 段数验证(纯 C 归零,Go c-shared 不归零)---" - (cd $W && ./exp01b.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1)) -fi - -# ── 01c: 版本化路径(需要两个真 Go c-shared)──────────────── -if sel 1c; then - banner "实验 1c: 版本化路径 dlopen 可加载新代码" - W=$WORK/e01c; mkdir -p $W/{v1,v2,host} - for V in v1 v2; do - cat > $W/$V/main.go < $W/$V/go.mod - (cd $W/$V && go build -buildmode=c-shared -o ../gl$V.so . 2>&1|head -3) - done - cp 01-dlclose-nodelete/exp01c/main.go $W/host/ - sed -i '/^\/\/go:build ignore$/d' $W/host/main.go; prep_plain $W/host - (cd $W/host && go build -o ../h.bin .) && (cd $W && ./h.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1)) -fi - -# ── 02: 可行性 1-11 ─────────────────────────────────────── -declare -A XSYS=([1]=1 [2]=1 [4]=1 [8]=1 [10]=1) -for n in 1 2 3 4 5 6 7 8 9 10 11; do - sel $n || continue - banner "实验 $n" - W=$WORK/f$n; mkdir -p $W - case $n in - 1) cp 02-feasibility/exp1_eventfd.go $W/main.go ;; - 2) cp 02-feasibility/exp2_parent.go $W/main.go; cp 02-feasibility/exp2_child.go $W/ ;; - 3) cp 02-feasibility/exp3_parent.go $W/main.go; cp 02-feasibility/exp3_child.go $W/ ;; - 4) cp 02-feasibility/exp4.go $W/main.go ;; - 5) cp 02-feasibility/exp5b.go $W/main.go; cp 02-feasibility/exp5_plugin.go $W/ ;; - 6) cp 02-feasibility/exp6.go $W/main.go; cp 02-feasibility/exp6_crash.go $W/ ;; - 7) cp 02-feasibility/exp7.go $W/main.go ;; - 8) cp 02-feasibility/exp8.go $W/main.go; cp 02-feasibility/exp8_worker.go $W/ ;; - 9) cp 02-feasibility/exp9.go $W/main.go; cp 02-feasibility/exp9_worker.go $W/ ;; - 10) cp 02-feasibility/exp10.go $W/main.go ;; - 11) cp 02-feasibility/exp11.go $W/main.go; cp 02-feasibility/exp11_plug.go $W/ ;; - esac - # 去掉 main.go 的 build ignore(它是入口) - sed -i '/^\/\/go:build ignore$/d' $W/main.go - if [ "${XSYS[$n]:-}" = "1" ]; then prep_xsys $W; else prep_plain $W; fi - # 需要预编译的辅助二进制 - case $n in - 5) (cd $W && go build -o plugbin exp5_plugin.go 2>&1|head -3) ;; - 6) (cd $W && go build -o crashbin exp6_crash.go 2>&1|head -3) ;; - 11) (cd $W && go build -o plug11 exp11_plug.go 2>&1|head -3) ;; - esac - run_go $W "实验 $n" -done - -# ── 03: lost update ─────────────────────────────────────── -for e in 12 13; do - sel $e || continue - banner "实验 $e: 副本模型 lost update" - W=$WORK/l$e; mkdir -p $W - cp 03-lost-update/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go - prep_plain $W; run_go $W "实验 $e" -done - -# ── 04: cgo 不可中断 ────────────────────────────────────── -for e in 14a 14b; do - sel 14 || sel $e || continue - banner "实验 $e: cgo 调用不可中断" - W=$WORK/c$e; mkdir -p $W - cp 04-cgo-uninterruptible/hang.c $W/ - gcc -shared -fPIC -o $W/hang.so $W/hang.c - cp 04-cgo-uninterruptible/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go - prep_plain $W; run_go $W "实验 $e" -done - -banner "汇总: 通过 $PASS, 失败 $FAIL" -[ $FAIL -eq 0 ] diff --git a/docs/zh/input-scheduler-design.md b/docs/zh/input-scheduler-design.md index c4735c6..2c1d639 100644 --- a/docs/zh/input-scheduler-design.md +++ b/docs/zh/input-scheduler-design.md @@ -541,9 +541,9 @@ v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 st - **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值 等价于旧行为(L1)。 - 合回 `main` 前需完成的发布动作: - 1. 同步更新 `docs/zh/plugin-interface-matrix.md`; - 2. 与 SDK 仓协同升 SDK 中版本; - 3. 遵守“只增不减、签名不改”边界。 + 1. 与 SDK 仓协同升 SDK 中版本(`docs/git-branching.md` §七); + 2. 遵守“只增不减、签名不改”,并同步 hmapdev 模板接线 + (`docs/git-branching.md` §八)。 - 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`) 同步追加 `priority`,与公开 SDK 字段一一对应。 diff --git a/docs/zh/plugin-interface-matrix.md b/docs/zh/plugin-interface-matrix.md deleted file mode 100644 index db2b25f..0000000 --- a/docs/zh/plugin-interface-matrix.md +++ /dev/null @@ -1,429 +0,0 @@ -# 外部插件接口不变矩阵(多进程化整改基线) - -> 状态:**完成 v3**(2026-09-06)——v2 的迁移已上生产(内核 v1.0.0);v3 记录 v1.1.1 的公开接口**扩展**。 -> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致; -> 所有改造落在**核心(homed 侧)+ 工具链(hmapdev,当时名为 plugindev)**,外部插件业务代码零改动,只需用新工具链重编。 -> -> **结果(已验证)**:`git diff third_party/homeagent-sdk/sdk/` 全程为空;17 个 `example/*/plugin.go` 逐字节未改 -> (`git status example/` 无输出);生产 17 插件全部经子进程通道运行。 -> -> ⚠️ **v1.1.x 起冻结约束被有意解除**,因为「接口不变」这条约束本身是为**迁移期**设的: -> 它要保的是「换运行模型不动业务代码」。迁移完成后,SDK 需要能随功能演进而扩展, -> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。 -> -> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后, -> 必须同步更新本矩阵。 -> -> 权威编号:plan.md 第 11 节(11.1~11.9)。本文档只做接口面盘点,不做实现。 - ---- - -## 一、迁移的形状(一句话) - -``` -今天: 外部插件 = example/*/plugin.go(纯 Go) ──hmapdev c-shared──> plugin.so - homed ──dlopen──> plugin.so(C ABI bridge:51 个整数 method id) -之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──hmapdev go build──> plugin.bin - homed ──spawn──> plugin.bin(stdio JSON-RPC + shm + eventfd) -``` - -**为什么接口可以不变**(已代码核实): - -| 层 | 含 cgo? | 迁移后动作 | -|---|---|---| -| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) | -| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go(只 import 公开 SDK) | **不动**(只重编) | -| bridge 模板 `tools/hmapdev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` | -| `hmapdev` 构建命令 | c-shared | 改普通 `go build` | -| homed `internal/plugin/cabi/`(1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2) | -| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` | - ---- - -## 二、合同面 A:公开 SDK 类型与接口(迁移前后必完全一致) - -文件:`third_party/homeagent-sdk/sdk/{plugin.go,memory.go,knowledge.go,llm.go,settings.go}` - -### A1. 插件入口契约(Plugin 接口) - -```go -type Plugin interface { - Name() string - Start(sdk *PluginSDK) error - Stop() error -} -// 外部插件实现 NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) -``` - -### A2. 插件可注册的 5 类组件(PluginSDK 方法) - -| PluginSDK 方法 | 签名 | 外部插件使用量(example 实测) | -|---|---|---| -| `RegisterTool` | `(name string, def ToolDef, handler ToolHandler) error` | **86** | -| `RegisterStage` | `(stage Stage, handler StageHandler, scope ...StageScope)` | 6 | -| `RegisterOutputChannel` | `(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error` | 4 | -| `RegisterInputChannel` | `(name string, def ChannelDef) error` | 2 | -| `RegisterPluginAPI` | `(name string) error` | 0(定义存在,可用) | - -### A3. 插件可调用的能力访问器(PluginSDK 方法) - -| 访问器 | 返回 | 外部插件使用量 | -|---|---|---| -| `Settings()` | `SettingsAPI` | **17 插件全部使用**(Get/Set/List/GetCore/SetCore/ListCore/DataDir/GetPlugin/SetPlugin/ListPlugin/RegisterDef/Defs/Dump/Plugins) | -| `Memory()` | `MemoryAPI`(Recall/Commit/Introspect/MergeEntities/Purge) | 低(controllable) | -| `DocMemory()` | `DocMemoryAPI`(Query/Insert/**InsertWithMedia**/Remove/Stats) | 低(`InsertWithMedia` v1.1.0 新增) | -| `TextMemory()` | `TextMemoryAPI`(Append) | 0 当前 | -| `Knowledge()` | `KnowledgeAPI`(Search/Add/List) | 2 | -| `LLM()` | `LLMAPI`(ListSources/SetSource/CurrentSource) | 0 当前 | -| `Social()` | `SocialAPI`(**只读**:GetPerson/GetTrait/GetRelations/GetNetwork/ListPersons) | 0 当前 | -| `Events()` | `EventSubscriber`(Subscribe) | 0 当前(**C ABI 空实现**,迁移后可获得) | -| `PluginMgr()` | `PluginMgrAPI`(ReloadOne/ListLoadedPlugins/IsPluginDisabled) | 0 当前 | -| `AutoRestart()` | `bool` | 配套 SetAutoRestart 用 | - -### A4. 生命周期 / 工具注入(PluginSDK 方法) - -| 方法 | 签名 | 备注 | -|---|---|---| -| `SetAutoRestart` / `AutoRestart` | `(bool)` / `() bool` | example 使用 16 次 | -| `InjectText` | `(source, channel, text string)` | → C ABI case 5 | -| `InjectInterruptText` | `(source, channel, text string)` | example 使用 6 次 → case 6 | -| `InjectTextNoMemory` | `(source, channel, text string)` | → case 7 | -| `InjectInputSync` | `(source, channel, text string) string` | → case 47(例:qq 闭环) | -| `SetToolBlocks` | `(blocks []ContentBlock)` | ✅ **v1.1.1 已落地**(`io.setToolBlocks`);同版补上 `PluginSDK` 侧一直缺失的便捷包装——接口里有、便捷方法里没有,插件此前只能自己去拿 injector | -| `InjectInputMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectMedia`。与 `SetToolBlocks` 的区别见下方说明 | -| `InjectInputMediaSync` | `(source, channel, text string, blocks []ContentBlock) string` | **v1.1.0 新增** → `io.injectMediaSync` | -| `InjectInterruptMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectInterruptMedia` | - -**为何媒体注入不能搭 `SetToolBlocks` 的车**:后者只在**工具处理函数内部**可用,且媒体要等 -**下一条 tool message** 才到模型手上。插件主动发起一轮带媒体的对话、以及中断注入, -需要各自的签名,且媒体在**本轮**就随消息发出,并自动落进 CAS、挂上媒体记忆引用。 -| `RegisterStopHandler` / `RunStopHandlers` | `(func())` / `()` | 已有(qq 等 1 次) | -| `RegisterOnRemoveHandler` / `RunOnRemoveHandlers` | `(func())` / `()` | example 使用 3 次 | -| `Set*`(SetIOInjector/SetMemoryAPI/.../SetPluginMgrAPI) | — | 供 bridge/核心启动时接线,插件不直接调 | - -### A5. 核心数据类型(迁移前后结构体字段/JSON tag 不变) - -| 类型 | 关键字段 | 备注 | -|---|---|---| -| `StageContext` | 16 字段:RawMessage/UserID/GroupID/ContextMsgs/LLMText/ReasoningContent/TokenUsage/ToolCalls/ToolResults/FinalText/Response/Phase/Memory/NoMemory/Extra/Errors + Lock/RLock/Unlock/RUnlock/IsResponded | **注意**:外部插件经 C ABI 只能看到 10 个字段(见 C3),迁移到共享内存后可看到全部 16 个 | -| `ToolDef` | Name/Plugin/Description/Parameters/NoMemory/Cleaner(func) | `Cleaner` 是函数,**无法过 C ABI**(迁移后经 RPC/进程内保留) | -| `ChannelDef` | NoMemory/Cleaner(func) | 同上 | -| `ToolCall` / `ToolResult` / `MemItem` | ID/Name/Plugin/Arguments;CallID/Name/Plugin/Success/Result;Role/Content/Score | 全部纯 JSON 可序列化 | -| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURL;URL/Detail;URL | 全部可偏移化(迁移评估 3.3 已核实) | -| `MediaAttachment`(**v1.1.0 新增**) | Digest/MIME/Data/Name/Description | 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节去重),只给 `Digest` 是引用已有内容。**读路径不回 `Data`**——一次检索可能命中几十份媒体,全塞回去会撑爆跨进程消息 | -| `Event` / `EventHandler` / `EventSubscriber` | Type/Source/Payload/Timestamp | 迁移后才对外部插件真正可用 | -| `Triple` / `Entity` / `Relation` / `Doc` / `TextEvent` / `PersonProfile` / `SocialRelation` / `Knowledge` / `ConfigDef` | — | 全部 JSON 可序列化 | -| `Triple`(**v1.1.0 扩展**) | += `SentenceText` / `MediaDigests` | 媒体引用挂在**句子**上(`SentenceText` → `sentences` → `sentence_id` → `media_refs`),所以 `MediaDigests` 非空而 `SentenceText` 为空时内核会用媒体标记本身充当句子 | -| `Doc`(**v1.1.0 扩展**) | += `MediaDigests` / `Attachments` | `Query` 返回时由内核填充(仅元数据,不带字节) | -| `TextEvent`(**v1.1.0 扩展**) | += `Attachments` | 写入时内核把标记并进正文;`RecentEvents` 读回时从标记反解 | - -**函数类型字段盘点(唯一无法跨进程序列化的东西)**: -- `ToolDef.Cleaner func(string) string` -- `ChannelDef.Cleaner func(string) string` -- `StageContext.mu sync.RWMutex`(~~锁~~ → 迁移后映射到跨进程锁仲裁) -- 各种 `ToolHandler`/`StageHandler`/`EventHandler`/`func()`(回调 → RPC 反向注册) - -→ 这些正是共享内存 + RPC 要保的「留在进程内的回调型资源」(迁移评估 3.5)。 - ---- - -## 三、合同面 B:bridge 51 个 method id ↔ SDK 方法映射(改造基线) - -> ⏹️ **已完成(2026-09-03)**:整数 method id 已全部平移为 RPC method 名字符串, -> 定义在 `internal/plugin/proc/protocol.go` 的 `Method*` 常量(共 60 个,含内核→插件方向)。 -> 原 `tmplLinuxBridge` 与 `meta.Core` 整数表**均已删除**。 -> -> 两个遗留点:`case 25`(`CoreFreeString`)无对应 method(内存管理是 C 层特有问题); -> `io.setToolBlocks` 已定义但内核侧仍返回未实现(C ABI 时代也是空实现,非回归)。 -> -> 下表保留作为历史对照。 - -| # | method id(今天 C ABI) | SDK 背的方法 | 迁移后 RPC method 名(建议) | -|---|---|---|---| -| 1 | CORE_REGISTER_TOOL | RegisterTool | `tool.register` | -| 2 | CORE_REGISTER_STAGE | RegisterStage | `stage.register` | -| 3 | CORE_REGISTER_OUTPUT_CH | RegisterOutputChannel | `output.register` | -| 4 | CORE_REGISTER_PLUGIN_API | RegisterPluginAPI | `api.register` | -| 5 | CORE_INJECT_TEXT | InjectText | `io.injectText` | -| 6 | CORE_INJECT_INTERRUPT_TEXT | InjectInterruptText | `io.injectInterrupt` | -| 7 | CORE_INJECT_TEXT_NO_MEMORY | InjectTextNoMemory | `io.injectTextNoMem` | -| 47 | CORE_INJECT_INPUT_SYNC | InjectInputSync | `io.injectInputSync` | -| 8 | CORE_SET_AUTO_RESTART | SetAutoRestart | `lifecycle.autoRestart` | -| 9 | CORE_MEMORY_RECALL | Memory().Recall | `memory.recall` | -| 10 | CORE_MEMORY_COMMIT | Memory().Commit | `memory.commit` | -| 11 | CORE_MEMORY_INTROSPECT | Memory().Introspect | `memory.introspect` | -| 12 | CORE_MEMORY_MERGE | Memory().MergeEntities | `memory.merge` | -| 13 | CORE_MEMORY_PURGE | Memory().Purge | `memory.purge` | -| 14 | CORE_DOC_QUERY | DocMemory().Query | `doc.query` | -| 15 | CORE_KNOWLEDGE_SEARCH | Knowledge().Search | `knowledge.search` | -| 16 | CORE_SETTINGS_GET | Settings().Get | `settings.get` | -| 17 | CORE_SETTINGS_SET | Settings().Set | `settings.set` | -| 18 | CORE_SETTINGS_REGISTER_DEF | Settings().RegisterDef | `settings.registerDef` | -| 19 | CORE_LLM_LIST_SOURCES | LLM().ListSources | `llm.listSources` | -| 20 | CORE_LLM_SET_SOURCE | LLM().SetSource | `llm.setSource` | -| 21 | CORE_SOCIAL_GET_PERSON | Social().GetPerson | `social.getPerson` | -| 22 | CORE_SOCIAL_GET_NETWORK | Social().GetNetwork | `social.getNetwork` | -| 23 | CORE_SUBSCRIBE | Events().Subscribe | `events.subscribe`(**今天空实现**) | -| 24 | CORE_UNSUBSCRIBE | (退订闭包) | `events.unsubscribe`(**今天空实现**) | -| 25 | CORE_FREE_STRING | (内存释放) | 删除(RPC 无此概念) | -| 26 | CORE_SETTINGS_GET_CORE | Settings().GetCore | `settings.getCore` | -| 27 | CORE_SETTINGS_SET_CORE | Settings().SetCore | `settings.setCore` | -| 28 | CORE_SETTINGS_LIST_CORE | Settings().ListCore | `settings.listCore` | -| 29 | CORE_SETTINGS_GET_PLUGIN | Settings().GetPlugin | `settings.getPlugin` | -| 30 | CORE_SETTINGS_SET_PLUGIN | Settings().SetPlugin | `settings.setPlugin` | -| 31 | CORE_SETTINGS_LIST_PLUGIN | Settings().ListPlugin | `settings.listPlugin` | -| 32 | CORE_DOC_INSERT | DocMemory().Insert | `doc.insert` | -| 33 | CORE_DOC_REMOVE | DocMemory().Remove | `doc.remove` | -| 34 | CORE_DOC_STATS | DocMemory().Stats | `doc.stats` | -| 35 | CORE_KNOWLEDGE_ADD | Knowledge().Add | `knowledge.add` | -| 36 | CORE_KNOWLEDGE_LIST | Knowledge().List | `knowledge.list` | -| 37 | CORE_LLM_CURRENT_SOURCE | LLM().CurrentSource | `llm.currentSource` | -| 38 | CORE_SOCIAL_GET_TRAIT | Social().GetTrait | `social.getTrait` | -| 39 | CORE_SOCIAL_GET_RELATIONS | Social().GetRelations | `social.getRelations` | -| 40 | CORE_SOCIAL_LIST_PERSONS | Social().ListPersons | `social.listPersons` | -| 41 | CORE_TEXT_MEMORY_APPEND | TextMemory().Append | `textmemory.append` | -| 42 | CORE_SETTINGS_LIST | Settings().List | `settings.list` | -| 43 | CORE_SETTINGS_DEFS | Settings().Defs | `settings.defs` | -| 44 | CORE_SETTINGS_DUMP | Settings().Dump | `settings.dump` | -| 45 | CORE_SETTINGS_PLUGINS | Settings().Plugins | `settings.plugins` | -| 51 | CORE_SETTINGS_DATA_DIR | Settings().DataDir | `settings.dataDir` | -| 46 | CORE_REGISTER_INPUT_CH | RegisterInputChannel | `input.register` | -| 48 | CORE_PLUGIN_RELOAD_ONE | PluginMgr().ReloadOne | `plugin.reloadOne` | -| 49 | CORE_PLUGIN_LIST_LOADED | PluginMgr().ListLoadedPlugins | `plugin.listLoaded` | -| 50 | CORE_PLUGIN_IS_DISABLED | PluginMgr().IsPluginDisabled | `plugin.isDisabled` | - -**bridge 侧反向调用(内核 → 插件,RPC 的另一半)**: - -| 今天 | 迁移后 | -|---|---| -| `go_invoke_tool(name, argsJSON)` | `tool.invoke`(homed → pinvoke) | -| `go_invoke_stage(stage, ctxJSON, resultOut)` | `stage.invoke`(homed → pinvoke,共享内存数据面) | -| `go_invoke_output(channel, type, payloadJSON)` | `output.invoke`(homed → pinvoke) | -| `go_free_string` | 删除 | - ---- - -## 四、合同面 C:StageContext 跨 ABI 现状 → 共享内存目标 - -> ✅ **已达成(2026-09-03)**:子进程插件现在看到全部 18 个字段(枚举见 -> `internal/plugin/proc/shm.go`),且可写回。生产实测:sanitizer 在另一个进程里 -> 改写 13590 字节文本,内核读到改写结果(`stage post_action 改写了 1 个字段`)。 - -### C1. 迁移前(C ABI 副本模型):插件只看到 10 个字段 - -`stageContextWritable`(templates.go:762)下发/回传的字段: - -``` -raw_message user_id group_id phase llm_text final_text no_memory -+ response(可选) + tool_calls(有才传) + tool_results(有才传) -``` - -**看不到的 6 个字段**:`ContextMsgs` / `ReasoningContent` / `TokenUsage` / `Memory` / `Extra` / `Errors` - -### C2. 迁移后(共享内存 + 锁仲裁):插件可看到/改写全部字段 — ✅ 已实现 - -字段级 `Slice{Off,Len}` 描述符 + 内核仲裁锁。插件进程内保留原生 `StageContext`, -handler 照常读写,`Lock/RLock` 映射到跨进程锁仲裁 RPC(`stage.lock`/`stage.unlock`), -handler 返回时脏字段写回共享段。 - -**关键设计决定**:全部子进程插件共享**同一块 memfd**。第一版设计是每插件一段, -那会退化成副本模型,复现 §8.4 的 35.8~36.8% lost update。 - -→ **接口形式不变,能力变强**(能力断层消除:外部插件拿回 ContextMsgs 等)。 - -Windows 同步受益:从「只下发 3 字段、无写回」升到全字段可见 + 写回, -与 Unix 共用同一套 RPC 实现与共享段布局。 - -### C3. lost update 的合同面定义 — ✅ 已消除 - -C ABI 时代 `stageContextWritable` **无条件回传 10 个字段的当前快照**——两个插件 -(sanitizer 改 ToolResults + weather 只读)并行时,weather 的回传会覆盖 sanitizer -的清洗结果(实测 1.6~4.3%,高并发下 35.8~36.8%)。 - -Part 0.2 先做了过渡补丁(只回传真正变更的字段);Part 4 的共享内存模型从根上解决 -(字段级描述符 + 锁仲裁,并发改写同一对象)。 - -回归基线:`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`、 -`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`。 - ---- - -## 五、外部插件实际触达面(example 18 插件实测汇总) - -> 这是「17 个存量插件业务代码零改动」的直接依据——它们**只用**下表这些 API,全部在公开 SDK 合同面内。 - -| 插件 | 用到的 SDK 触达 | -|---|---| -| qq(最复杂) | SetAutoRestart / RegisterDef×11 / RegisterOutputChannel(qq, 4 caps) / RegisterInputChannel(qq, NoMemory+Cleaner) / RegisterStage(BeforeToolcall, OwnTools) / RegisterTool×N / InjectInterruptText×2 / getSetting(p.sdk.Settings()) | -| weather / rss / bili / ocr / files / memo / music / a2a / acp / ai_image / browser / calendar / editdoc / recoverydiag / sanitizer / vanblog / luademo | RegisterTool / Settings / SetAutoRestart / (部分) RegisterStage / RegisterOutputChannel / InjectInputSync / Knowledge / RegisterStopHandler / RegisterOnRemoveHandler | - -**结论**:外部插件触达面 ⊆ 公开 SDK 合同面;无任何插件直接使用方法 id 或 bridge 内部符号。 -→ 只要公开 SDK 签名不变 + bridge 语义平移,接口不变约束成立。 - ---- - -## 六、迁移后外部插件「新获得」的能力(合同面扩展——只增不减) - -| 能力 | 迁移前 | 迁移后 | 实际结果 | -|---|---|---|---| -| 事件订阅 `Events().Subscribe`(case 23/24) | ❌ 空实现 | ✅ 事件环(EvtRing + eventfd + 独立游标) | ✅ 已接线(当前零用户) | -| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ `io.setToolBlocks` | ✅ **v1.1.1 已落地**(走 JSON 而非共享段二进制通道,理由见 §九) | -| 媒体入记忆(`InsertWithMedia`、`Triple.MediaDigests`) | ❌ 不存在 | ✅ CAS + 引用计数 GC | ✅ **v1.1.0 类型 / v1.1.1 内核实现** | -| 插件主动发起带媒体的一轮对话(`InjectInputMedia*`) | ❌ 不存在 | ✅ 媒体在本轮就到模型手上 | ✅ **v1.1.1** | -| `ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` | ❌ 看不到 | ✅ 共享内存全字段 | ✅ 18 字段全可见可写 | -| 插件崩溃隔离 | ❌ panic 带崩 homed | ✅ 子进程独立崩溃 | ✅ 测试 + 生产验证 | -| 热重载 `.so` | ❌ `DF_1_NODELETE` no-op | ✅ 同路径替换 `.bin` 即生效 | ✅ 生产实测 | -| 工具超时取消 | ❌ cgo 不可中断(泄漏线程) | ✅ `Process.Kill()` 真取消 | ✅ 整套新架构零 cgo | -| `output_send` 结果 | ❌ 永远假成功 | ✅ 可同步等真实结果 | ✅ 生产实测 `map[status:sent]` | -| Lua/Windows DLL 路径 | ❌ 三套 ABI 分裂 | ✅ 收敛为单一 RPC 实现 | ⚠️ Windows 已收敛;Lua 仍独立(留待后续) | - -**三项未完全兼得的说明**: - -- `SetToolBlocks`:`io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`,但内核侧 handler - 仍返回未实现。C ABI 时代它也是空实现(§1.4),故**不是回归**,但也没兑现承诺。 -- Lua:`lua_plugin.go`/`dynamic_lua.go` 仍走自己的路径。Lua 经解释器不经 C ABI, - 不属于本轮要消除的 6 类缺陷,因此不阻塞。收敛第三套 ABI 是独立优化。 -- 事件订阅:机制已完成(内核侧 `EvtRing` + 模板侧 `evtConsumerLoop`), - 但**无任何现有插件使用 `Events().Subscribe`**,所以生产上未经真实负载检验。 - -**刻意不给**(权限梯度显式化,非技术限制):`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/ -`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish` -(内核内部机制)。清单与理由记在 `internal/plugin/proc/capability.go` 的 -`withheldCapabilities`,`TestCapability_WithheldListIsDocumented` 守护。 - -这一项从「C ABI 表达能力的意外产物」变成**显式策略**:以前拿不到是因为 -C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 case 就能捅穿); -现在是三道闸:类型层(`procCore` 命名字段不嵌入)+ 能力集(manifest 声明) -+ RPC 边界(返回明确错误而非静默忽略)。 - ---- - -## 七、接口冻结检查点(全部已通过) - -1. ✅ **阶段 2(子进程通道原型)**:`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。 - 验收:weather 业务代码逐字节未改(`git status example/` 无输出)。 -2. ✅ **阶段 3(共享内存)**:子进程并发改写 StageContext 丢失率 = 0% - (`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` 与 - `TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`)。 -3. ✅ **阶段 5**:17 个外部插件全部 `.bin` 化、cabi 删除(-3198 行); - `go build ./...` 与全仓 `go test ./...` 均通过。 -4. ✅ **全程**:`git diff third_party/homeagent-sdk/sdk/` 为零——接口冻结的硬证据。 -5. ⚠️ **v1.1.x 起该检查项不再适用**:冻结是迁移期的约束,迁移完成即到期(见 §九)。 - 取代它的门禁是「存量插件零改动零重编」——见 §九的验证方式。 - -生产端到端(2026-09-03,真实 QQ 消息): - -``` -input from qq → response (83293ms, tools=[qq_get_message qq_get_history - output_send__qq output_send__qq qq_mark_read]) -[sanitizer] cleaned 2 bytes (before=13590 after=13588) -[proc] sanitizer stage post_action 改写了 1 个字段 -tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent] -``` - ---- - -## 九、v1.1.x 的接口扩展规则(冻结解除后的替代约束) - -冻结约束是为**迁移期**设的:它要保的是「换运行模型不动业务代码」。迁移完成后继续冻结, -等于让 SDK 永远停在迁移那天的能力面——多模态这类功能永远到不了插件手上。 - -取代它的是三条更弱但仍然硬的约束: - -### 1. 只增不减,签名不改 - -新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。 - -实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数, -或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败, -而那些插件根本不关心媒体。 - -### 2. 新增方法必须是「插件调用、内核实现」方向 - -这是**存量插件不需要重编**的技术原因:`IOInjector` 新增三个方法后,插件只是 -*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法, -每个存量插件都会因未实现而编译失败。 - -因此 `SDKCompatibleVersion` 与 SDK 的 `CoreVersion` 都不必随之跃迁: -1.1.0 的 SDK 配 1.0.0 编的插件仍然成立。 - -### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败** - -公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` / -`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败 -不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个 -E2E 用例编译失败),不是靠人工检查发现的。 - -完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 → -`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 → -测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。 -还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,所以漂移不会被编译器抓到 -(v1.1.1 修的时候发现它的 `Triple` 用的是 `Predicate`,而公开 SDK 一直叫 `Relation`)。 - -### 验证方式(取代「diff 为零」) - -| 检查 | 命令 | v1.1.1 结果 | -|---|---|---| -| 存量插件源码零改动 | `cd example/ && go vet ./...`(17 个) | ✅ 17/17 通过 | -| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin` 跑 `TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) | -| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method | -| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE(13 例压测) | - -### v1.2.x 的接口扩展(2026-09-12) - -1.2.0 把「记不记入记忆 / 要不要据此裁剪上下文」从**只有工具与通道能声明**,扩到**注入侧也能声明**: - -| 新增 | 方向 | 说明 | -|---|---|---| -| `InjectOptions{NoMemory, ContextPolicy, CleanerName}` | 新增类型 | 单次注入的行为声明 | -| `ContextPolicyNone` / `ContextPolicyPrune` + `ValidContextPolicy` | 新增常量/函数 | 取值只有 `""` / `none` / `prune`;`prune` 必须显式声明 | -| 六个 `*Opts` 变体(Text / InterruptText / InputSync / InputMedia / InputMediaSync / InterruptMedia) | 插件调用、内核实现 | 旧的三参数方法保留为**零值糖**,与 `InjectOptions{}` 逐键等价 | -| `ChannelDef.ContextPolicy` + `ChannelDef` 的 JSON tag | 结构体字段 | 通道也可声明裁剪;补 tag 是因为通道定义要跨进程传给内核,而 `Cleaner` 是函数必须忽略——无 tag 时新增字段会被**静默丢掉** | - -签名层面零变更(六个方法全是新增),满足第 1、2 条。 - -**但「接口纯追加」不等于「无需重编」**:1.2.0 同时把插件运行协议升到 2 -(fd3 布局改变,不支持滚动升级),`ProtocolVersion` 不匹配会在握手时被明确拒绝 -并提示用配套 plugindev 重编。两件事必须分开说,否则会被误读成「既然纯追加就还能用旧产物」。 - -#### 这次扩展自己抓出来的两处漂移(都是本节第 3 条要防的那类) - -1. **模板接线守卫红了**:`TestProcTemplate_CoversAllCoreMethods` 要求模板出现内核提供的 - 每一个 method id,而注入标志位落地后模板不再发 `io.injectTextNoMem`(旧模板发它, - 现在走 `io.injectText` + `NoMemory` 标志位)。内核保留该 id 是**刻意的向后兼容面** - (用那时模板编出的二进制仍在外面),不是漏接线——所以改的是判据:把它移入显式的 - `deprecated` 表,并加**反向保护**(条目一旦重新出现在模板里就报错,避免这张表 - 退化成「永久豁免」的垃圾抽屉)。 -2. **mocksdk 缺一个方法**:拿公共 SDK `IOInjector` 的 14 个方法名与 mock 的方法集 - **机械求差**,差集恰好是旧的三参数 `InjectInputSync`——通道类插件(qq / a2a)完成 - 「入站 → agent 处理 → 回复取回」闭环要调的那个。`git log -S` 证实它**从来就缺**, - 不是本次引入;补齐后差集为空。(上次漂的是 `Triple.Predicate` vs `Relation`,同一类问题。) - -#### 验证(1.2.0,本机实测) - -| 检查 | 命令 | 结果 | -|---|---|---| -| 存量插件源码零改动 | 逐个 `cd example/ && go vet ./...` | ✅ 17/17 通过(`luademo` 是 Lua、无 `go.mod`,跳过) | -| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ 全绿(修复前为红;反向保护另用「把 id 塞回模板」验证过会报错) | -| 并发安全 | `go test -race -count=5 ./sdk/` | ✅ ok | -| mocksdk 未漂移 | 方法集求差(14 个方法) | ✅ 差集为空 | - -### 为何媒体块走 JSON 而不是共享段二进制通道 - -`SetToolBlocks` 的原设计是「二进制落 arena,Slice 描述符回传」。实际落地时改走 JSON: -data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要让这四个 method 跟其余 -51 个分道扬镳。共享段的价值在于**并发改写同一份状态**(StageContext 的 lost update), -而媒体块是单向传递的不可变数据,没有这个问题。 - ---- - -## 八、关联文档 - -- `docs/zh/架构迁移评估.md` — 完整论证(§3.2 method id 平移、§3.3 数据面、§3.4 SDK 封装、§3.5 回调型资源、§3.8 能力对齐) -- `plan.md` §11 — 11.1~11.9 修复清单(唯一权威编号) -- `third_party/homeagent-sdk/sdk/` — 合同面 A 的代码实现(全程零 diff) -- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板) -- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举) -- `internal/plugin/proc/capability.go` — 权限梯度(capability 组 + `withheldCapabilities`) -- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件) -- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具 \ No newline at end of file diff --git a/docs/zh/resident-subagent-design.md b/docs/zh/resident-subagent-design.md index a1be4be..9964dd9 100644 --- a/docs/zh/resident-subagent-design.md +++ b/docs/zh/resident-subagent-design.md @@ -677,4 +677,4 @@ go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal - 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。 - 若需要动公开 SDK(例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**" - 追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。 + 追加,并按 `docs/git-branching.md` §八 的接线清单同步(含 hmapdev 模板)与 SDK 仓版本(§七)。 diff --git a/docs/zh/架构迁移评估.md b/docs/zh/架构迁移评估.md deleted file mode 100644 index 8d1bbef..0000000 --- a/docs/zh/架构迁移评估.md +++ /dev/null @@ -1,1621 +0,0 @@ -# 插件架构迁移评估:从 C ABI 动态库到子进程 + 共享内存 - -> 状态:**评估稿 + 三轮验证已完成**(18 项可复跑实验) -> · 第七章 可行性实验:11 项全部通过(另有 01 组 3 项 + 04 组 2 项,合计 18) -> · 第八章 代码检查:发现外部插件 stage 一直是副本模型,实测 36.8% lost update -> · 第九章 补盲分析:新发现 4 类缺陷,其中 2 项**正在生产环境造成故障** -> -> ❗ **现网正在发生的问题**(详见 9.3/9.4/8.6): -> `output_send` 永远返回成功(已 2 次)、cgo 超时线性泄漏(已 26 次)、stage 数据污染(量级百分之几) -> -> 结论摘要:现有 `c-shared + dlopen` 架构存在无法修复的热重载缺陷与能力天花板, -> 两者同源于 C ABI 这一前提。迁移到子进程模型可一次性消除,并让 C 中间层整体退场。 -> -> **可执行修复项与勾选清单见 [`plan.md` 第 11 节](../../plan.md)**;本文档负责论证、实验与架构设计。 - ---- - -## 零、给接手者的阅读指引 - -> **先读本章再读其余部分**,否则极易被前六章的过时表述误导。 - -### 0.1 本文档是增量写成的,前后章节结论不同 - -文档按三轮工作递进追加,**前面的章节保留了当时的认识**(便于追溯推理过程), -但其中若干结论已被后续章节推翻或修订。**冲突时一律以编号更大的章节为准。** - -| 章 | 写作时的信息基础 | 可信度 | -|---|---|---| -| 一 ~ 六 | 代码阅读 + 推理 | ⚠️ **部分已被推翻**,见 10.2 对照表 | -| 七 | 11 项新架构可行性实验 | ✅ 实测 | -| 八 | 精读 ABI 链路 + 复刻实验 | ✅ 实测,**推翻了 2.4 的核心前提** | -| 九 | 遍历全部加载路径 + journal 统计 | ✅ 实测 + 现网数据 | -| 十 | 元信息 | — | - -**最重要的一处推翻**:2.4 节称「stage 并发扇出改写同一 `StageContext`」, -该表述对**内置插件**成立,但**外部 `.so` 插件从未共享过 `StageContext`**—— -它们走「快照-副本-写回」(见 8.1)。若按 2.4 的字面理解去改代码会走错方向。 - -### 0.2 三件事的准确定位 - -接手时最容易混淆的三个概念,这里一次说清: - -**① 并发扇出是原始设计,不是缺陷。** -`stages.go:124` 用 `go func` + `wg.Wait()` 并发调用所有 stage handler, -`StageContext` 的 `sync.RWMutex` 与公开的 `Lock/RLock` 就是为此准备的协作机制。 -**设计是对的。** 问题在于 C ABI 无法传递 Go 对象引用, -外部插件被降级为副本模型,那把为协作而生的锁在 ABI 边界外变成空转 -(实测:同一并发设计下内置 0% 丢失,副本 35.8~36.8% 丢失)。 - -**② 内置插件的高权限是刻意设计,不是"自己人所以安全"。** -但当前实现把「应有的权限梯度」与「C ABI 的表达能力天花板」混在了一起: -外部插件拿不到 `OutputChan`/`Subscribe` 是**技术限制**(Go channel、闭包 -过不了 C ABI),而非权限决定——证据是 `loader.go` 的 `case 23/24` -(事件订阅)是**空实现**,属于"给不了"而非"不给"。 -迁移目标是让梯度从**技术意外**变成**显式声明并强制的策略**,**不是消除梯度**。 - -**③ 副本模型是"为方便插件加载的无奈之举",不是设计失误。** -C ABI 是为绕开 Go 原生 `plugin` 包的同版本限制而引入的,副本模型是它的必然代价。 -批评应指向"该代价未被记录、其后果(lost update)未被发现",而非当初的选择。 - -### 0.3 修复项以 plan.md 为唯一权威 - -本文档出现过多套编号(4.1 的 `0.x`、8.9、9.6 的 `A-F`), -**均已统一收敛到 [`plan.md` 第 11 节](../../plan.md) 的 `11.1`~`11.6`**。 - -| plan.md | 内容 | 本文档论证位置 | -|---|---|---| -| 11.1 | `output_send` 假成功 | 9.4 | -| 11.2 | cgo 超时不可中断 | 9.3 | -| 11.3 | stage 副本 lost update | 8.1~8.6 | -| 11.4 | Lua 缺读锁 | 9.1 | -| 11.5 | Windows 能力退化 | 9.2 | -| 11.6 | reload 语义谎言 | 1.1 / 1.2 | -| 11.7 | 子进程化迁移(待决策) | 三~七章 | - -本文档中的 `0.x` / `A-F` 编号**仅供追溯当时的分组思路**,实施时不要使用。 - -### 0.4 哪些结论未经实测 - -| 表述 | 状态 | -|---|---| -| 9.2 Windows DLL 只下发 3 字段且无写回 | ⚠️ **仅代码阅读,无 Windows 环境实测** | -| 11.1 修复方案「不构成 cgo 嵌套」 | ⚠️ **推理,实施前必须实测** | -| 9.1 Lua DATA RACE 会实际触发 | ⚠️ 现网无 Lua 插件,**未触发过** | -| 3.x 目标架构的全部设计细节 | ⚠️ 机制经实验验证,**完整实现未写** | - -其余带 ✅ 的均有 [`experiments/plugin-arch/`](experiments/plugin-arch/) 下的 -可复跑实验支撑(`./run.sh`,18 项)。 - -### 0.5 现网正在发生的问题(若只读一段,读这段) - -| 问题 | 现网次数 | 影响 | 修复 | -|---|---|---|---| -| `output_send` 永远返回成功 | 7 天内 **2 次** | 消息发不出,模型以为成功、不重试 | 11.1 | -| cgo 超时不可中断 | 14 天内 **26 次** | 每次泄漏 1 goroutine + 1 OS 线程,永久 | 11.2 | -| stage 清洗结果被覆盖 | 概率性,**量级百分之几** | 脏数据(ANSI 转义)进 LLM 上下文 | 11.3 | - -**这三项都不需要等迁移决策,可独立修复。** - ---- - -## 一、为什么要动 - -### 1.1 触发问题:插件热重载静默失效 - -更换 `plugin.so` 后调用 `plgreload`,内核报告 `reloaded: qq` 成功,但**运行的仍是旧代码**。 - -根因经实验确证: - -``` -readelf -d plugin.so - FLAGS: SYMBOLIC STATIC_TLS - FLAGS_1: NODELETE ← Go 链接器强制写入 -``` - -`DF_1_NODELETE` 使 `dlclose` 成为 no-op(返回 0 但不卸载)。同路径二次 `dlopen` -复用旧映像,新代码永不生效。 - -三组对照实验(`/proc/self/maps` 段数为准): - -| 场景 | dlclose 后映射段数 | 结论 | -|---|---|---| -| 第三层是纯 C `.so` | 5 → **0** | 可真正卸载,换代码生效 | -| Go c-shared,Go 宿主直接 dlopen | 5 → **5** | 未卸载 | -| Go c-shared,**经纯 C shim** dlopen | 5 → **5** | 仍未卸载 | - -第三行是决定性的:**`NODELETE` 属于被卸载对象自身的 ELF 属性,与谁调用 `dlopen` 无关**。 -套任何层数的 C 中间件都绕不过去。 - -上游明确不支持(golang/go#11100):Go runtime 的信号处理器是进程全局的, -`sysmon`/GC worker/scavenger 常驻 OS 线程,静态 TLS 嵌进线程布局—— -patch 掉标记只会把静默失效换成随机崩溃。 - -### 1.2 曾评估并否决的绕行方案 - -**版本化路径 dlopen**(`plugins/qq/.load/plugin-<时间戳>-qq.so`):技术上成立,实测有效。 - -``` -1) 装载 1700000001-qq.so handle=0x36add080 ver=v1-CODE -2) 主 so 更新为 v2,复制到 1700000002-qq.so -3) dlclose 旧句柄 rc=0(旧映像不释放,预期) -4) 装载新路径 handle=0x36ade440 ver=v2-CODE ← 新代码生效 -``` - -同时确认 Go c-shared **无 SONAME**,不会被 glibc 按名去重,换路径确实得到新映像。 - -**但代价不可接受**。30 次连续重载实测: - -``` -第 10 次: RSS +15380KB threads +42 -第 20 次: RSS +32048KB threads +112 -第 30 次: RSS +46644KB threads +168 -均摊: RSS +1575KB/次, 线程 +5.77/次 -``` - -每次重载**永久泄漏约 5.8 个线程**——每份残留 Go runtime 都带自己的 `sysmon`、 -GC worker、scavenger,永不退出且仍被调度。`GOMAXPROCS=1` 只能压到 4.0/次, -且会拖慢插件并发,杯水车薪。 - -对 24/7 常驻的 homed 而言,「永久泄漏」比「15 秒重启」糟糕得多:重启有界且自愈, -泄漏无界且单调劣化。**故否决。** - -### 1.3 更深的问题:内置与外部插件的能力断层 - -| | 方法数 | -|---|---| -| `internal/sdk`(内置插件用) | 28 | -| 公开 SDK(外部插件用) | 36 | - -数字接近,但内置独有的恰恰是**「活的 Go 对象」**: - -``` -OutputChan() <-chan *agentIO.OutputEvent Go channel -RegisterChannel(dev agentIO.Device) Go 接口(含方法集) -Subscribe(type, handler) func() 回调 + 返回退订闭包 -Publish / Config / Tool / Indexer 直接持有内核注册表 -Selftest / Status / Supervisor / Tracker 内核内部机制 -SetToolBlocks 多模态注入 -``` - -**关键区分**:内置插件的高权限是**刻意的设计决策**,不是"自己人所以安全"。 -但当前实现把两件事混在了一起: - -- **应该有的权限梯度**(`Supervisor`/`Tracker`/`Selftest` 只给内核内部) -- **C ABI 的表达能力天花板**(channel、接口方法集、闭包在进程边界外无表示) - -外部插件拿不到 `OutputChan` 是**技术限制**,不是权限决定。证据: - -```go -case 23: // CORE_SUBSCRIBE - // Events API not wired for external plugins - return 0 -case 24: // CORE_UNSUBSCRIBE - return 0 -``` - -事件订阅对外部插件是**空实现**。这不是"不给",是"给不了"。 - -迁移的价值不是消除权限梯度,而是**让梯度从技术意外变成显式声明并强制的策略**。 - -### 1.4 附带缺陷(同源于 C ABI) - -- `SetToolBlocks` 在 bridge 中是**空实现**——跨 ABI 无对应 method id -- 插件 panic 跨 C 栈,`recover` 兜不住就带崩整个 homed -- `plugin_install` 返回 `reload_required` 对 `.so` 是**误导性谎言** -- method id 编号出现历史断裂(`CORE_INJECT_INPUT_SYNC=47` 夹在 7 和 8 之间) -- `plugindev` 交叉编译需处理 cgo 工具链,Windows/ARM 目标需对应 C 编译器 - -**这些全是 C ABI 这一前提衍生的附属债务。前提一撤,债务自行消失。** - ---- - -## 二、现状盘点 - -### 2.1 插件规模 - -**内置插件 16 个**(`internal/plugins/all.go` 匿名 import + `init()` 自注册): - -``` -agentcli ai_image cfgmgr clawhubadapter cli cmd files healthcheck -localuse mcp multimodal pluginmgr remotedevice skillmgr timer webui -``` - -**外部 `.so` 插件 17 个**(`/home/newqqagent/plugins/`): - -``` -a2a acp ai_image bili browser calendar editdoc files memo -music ocr qq recoverydiag rss sanitizer vanblog weather -``` - -### 2.2 涉及代码规模 - -| 文件 | 行数 | 迁移后命运 | -|---|---|---| -| `internal/plugin/cabi/loader.go` | 951 | **删除** | -| `internal/plugin/cabi/types.go` | 66 | **删除** | -| `internal/plugin/cabi/loader.c` | 79 | **删除** | -| `internal/plugin/registry.go` | 922 | 改:加载分派 | -| `internal/plugin/dynamic_loader_unix.go` | 79 | **删除/替换** | -| `internal/sdk/plugin.go` | 365 | 基本不动 | -| 公开 `sdk/plugin.go` | 484 | 加访问器,签名不变 | -| `internal/agent/core/stages.go` | 189 | 改:stage 跨进程 | -| `internal/agent/core/plugin_health.go` | 128 | **不动**,只换信号源 | -| `internal/events/bus.go` | 97 | 加:事件环投递 | -| `plugindev/templates.go` 的 `tmplLinuxBridge` | 385 | **删除**(每插件一份) | -| `internal/plugin/lua_plugin.go` | 978 | 改:统一走 RPC(见 9.1) | -| `internal/plugin/dynamic_lua.go` | 24 | 改 | -| `internal/plugin/lua_util.go` | 60 | 保留 | -| `internal/plugin/dynamic_dll_windows.go` | — | **删除**(见 9.2,能力严重退化) | -| `internal/plugin/dynamic_loader_windows.go` | — | **删除** | -| `internal/plugin/dynamic_dll_stub.go` | — | **删除** | - -> ⚠️ **第九章更正**:此前只识别了 native + cabi 两类加载路径,实际有**四种** -> (cabi `.so`/`.dylib`/`.dll`、Lua `main.lua`、Skill `SKILL.md`、native 内置)。 -> Windows DLL 与 Lua 路径此前完全未评估,均存在独立缺陷(9.1/9.2)。 -> **子进程化的一个重要收益是把三套独立 ABI 实现收敛为单一 RPC 实现。** - -**可删除总量:1096 行 C ABI 层 + 385 行/插件的 bridge 模板。** - -`loader.go` 内 `C.CString`/`C.GoString`/`C.free` 共 38 处调用,纯边界税。 - -### 2.3 已有且完善、迁移时应保留的机制 - -**必须强调:现有 SDK 的生命周期管理远比表面完善,迁移是"重新接线"而非"重写"。** - -`plugin_health.go`(128 行,**逻辑完全复用**): - -``` -3 次崩溃 / 5 分钟窗口 → 标记 unhealthy -30 秒冷却 → 自动恢复 -pendingReloads() → 驱动 autoReloadPlugins -尊重 AutoRestartEnabled -``` - -`executeToolCall`(`toolcall.go:16`): - -```go -done := make(chan string, 1) -go func() { done <- a.executeToolCallInner(tc) }() -select { -case result := <-done: return result -case <-time.After(60 * time.Second): // 60 秒超时 -} -// + panic 捕获 → resolveToolPlugin → recordCrash -``` - -SDK 停止链路(**已验证被正确调用**): - -``` -Handle.Stop() → call_stop_plugin → go_stop_plugin - → sdk.RunStopHandlers() → plg.Stop() -RegisterStopHandler / RegisterOnRemoveHandler / SetAutoRestart -``` - -迁移时的唯一改动:**把"panic 捕获"换成"进程退出码/EOF 检测",喂给同一个 `recordCrash`。** -超时、冷却、自愈、优雅停止全部保持原样。 - -### 2.4 两条硬约束(决定新架构设计) - -**约束 A:stage 是并发扇出,多插件并发改写同一对象** - -`internal/agent/core/stages.go:124`: - -```go -func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) { - var wg sync.WaitGroup - for _, handler := range handlers { - wg.Add(1) - go func(fn sdk.StageHandler) { // ← 并发 - defer wg.Done() - if err := fn(ctx); err != nil { errCh <- err } - }(handler) - } - wg.Wait() -} -``` - -所有插件 handler **并发运行在同一个 `*StageContext`** 上,靠 `sync.RWMutex` + -公开的 `Lock/RLock/Unlock` 协调。这是刻意设计——那 17 个字段 -(`LLMText`/`ToolCalls`/`FinalText`/`Errors`…)就是给多插件协作改写消息体用的。 - -**这是共享内存的正当性所在。** JSON-RPC 副本模型下语义会崩坏:两个插件都改了 -`FinalText`,谁赢?现在的答案是"后者看到前者结果",可组合;副本合并则无解。 - -> ## ⚠️ 本节前提已被第八章推翻,勿据此改代码 -> -> 上述「并发扇出改写同一对象」**只对内置插件成立**。 -> 外部 `.so` 插件一直走「快照-副本-写回」(`loader.go:411-439` + `templates.go:768-787`), -> 其 `ctx.Lock()` 是**空操作**(锁的是副本自己的 mu),且实测存在 -> **35.8~36.8% 的 lost update**(实验 12)与**现网量级百分之几的数据污染**(实验 13)。 -> -> **两点务必分清**: -> - **并发扇出本身是正确的原始设计**(`stages.go:124`),`RWMutex` 就是为它准备的 -> - **失效的是跨 ABI 边界后的锁语义**,不是这个设计 -> -> 故共享内存的作用是**修复**副本模型的缺陷,而非"保持现有语义"。 -> 完整分析见 8.1~8.6;准确定位见 0.2。 - -**约束 B:`Bus.Publish` 是同步的,且流式输出每 token 发一次** - -`internal/events/bus.go:56`: - -```go -func (b *Bus) Publish(evt *Event) { - for _, h := range typeHandlers { - b.safeCall(h, evt) // ← 内联阻塞调用 - } -} -``` - -`EventContentDelta` / `EventReasoningDelta` 在 `accumulateStream` 里逐 token 发布 -(`process.go:388/470/479`)。 - -**若内核发通知时等待插件,流式输出会被拖成卡顿。** -故新架构的事件投递**必须严格 post-and-forget,绝不等待消费者**。 - -### 2.5 payload 体积实测(决定共享内存的定位) - -近两日工具调用结果: - -``` -样本=96 中位=93 B p90=130 B 最大=134 B 均值=83 B -``` - -此量级下 JSON 序列化 3-8 µs,LLM 单轮往返 2-8 秒,**IPC 开销占比约 0.0001%**。 - -**结论:共享内存的价值不在省序列化开销**,而在两点: - -1. 并发改写同一份 `StageContext`(约束 A) -2. 二进制零拷贝(未来多媒体 payload,避免 base64 的 +33% 体积与编解码) - -控制面用 JSON 完全够用——toolcall 结果最终都要 JSON 化交给模型。 - ---- - -## 三、目标架构 - -``` -今天: - homed ──dlopen──> plugin.so - ├─ cgo bridge 385 行(7 个 //export,27 处字符串转换) - └─ 51 个整数 method id 派发 - ↑ C 层唯一目的:绕开 Go plugin 包的同版本限制 - -之后: - homed ──spawn──> plugin(纯 Go 二进制,无 cgo) - │ - ├── stdio JSON-RPC 控制面:51 个 case 平移为 method 名 - ├── shm + 偏移 数据面:StageContext 并发改写、二进制零拷贝 - └── eventfd 通知面:事件环 post-and-forget -``` - -### 3.1 C 中间层为何整体退场 - -C 层存在的唯一理由是绕开 Go 原生 `plugin` 包的版本枷锁: - -``` -plugin.Open 要求:完全相同的 Go 版本 + 完全相同的依赖版本 + 同构建环境 -任一不符 → "plugin was built with a different version of package ..." -``` - -`c-shared` + `dlopen` 把接口面压成 C ABI 来规避,代价是 51 个整数派发和满地字符串转换。 - -**子进程模型下,进程边界本身就是 ABI 边界。** 两进程各带自己的 Go runtime, -版本/依赖/编译器全不相关——从根上不存在"同版本"问题。C 层解决的问题消失,C 层自己也就该消失。 - -连带消失的:`DF_1_NODELETE` 议题、版本化路径、重载配额、hash 目录、 -method id 编号维护、`SetToolBlocks` 空实现、cgo 交叉编译工具链。 - -### 3.2 method id 的处置 - -**51 个 case 不删,原样映射为 RPC method 名**: - -``` -case 17: // CORE_SETTINGS_SET → {"method": "settings.set"} -case 41: // CORE_TEXT_MEMORY_APPEND → {"method": "textmemory.append"} -case 48: // CORE_PLUGIN_RELOAD_ONE → {"method": "plugin.reloadOne"} -``` - -每个 case 体(参数解析、调用、错误处理)可整块搬移,只换取参数方式。 -语义不变,回归风险最小。 - -**但编号本身扔掉**:不再维护"下一个可用 id 是 52",加能力不用改两边常量表, -也不再出现 `47` 夹在 `7` 和 `8` 之间的历史痕迹。 - -### 3.3 数据面:偏移替代指针 - -共享段 = 定长头 + arena,所有变长数据用相对 arena 基址的 `{off, len}` 描述符。 -**相对偏移是关键**——各进程 `mmap` 到不同虚拟地址也能正确解引用。 - -```c -typedef struct { uint32_t off, len; } Slice; // 相对 arena 基址 - -typedef struct { - Slice type, text; - uint8_t has_image, has_audio; - Slice image_url, image_detail; - Slice audio_url; -} ShmContentBlock; - -typedef struct { - uint64_t seq; // 乐观读校验 - Slice raw_message, llm_text, reasoning, final_text; - uint8_t has_response; Slice response; - Slice media_type, input_source, output_channel; - uint32_t nblocks; Slice blocks; // → ShmContentBlock[] - uint32_t arena_used, arena_cap; -} ShmStageCtx; -``` - -**`Extra` 可完全偏移化——已核实全部使用点只有 4 个键**: - -``` -internal/agent/core/eventloop.go:178 - Extra = { media_blocks, media_type, input_source, output_channel } -process.go:41 读 Extra["media_blocks"].([]ContentBlock) -distill.go:472 写 Extra["output_channel"] -``` - -而 `ContentBlock` 自身全是可偏移化的: - -```go -ContentBlock{ Type, Text string; ImageURL *ImageURL; AudioURL *AudioURL } -ImageURL{ URL, Detail string } AudioURL{ URL string } -``` - -**没有任何 `interface{}`、函数或 Go 特有引用类型。** 那两个指针只表达"可选", -用 `has` 标志位 + 内联结构替代。`Extra` 的 `interface{}` 是**形式上的**动态类型, -实际是封闭可判别的联合。 - -**决策:4 个键提升为共享段具名字段,`Extra` 本身保留 RPC 副本语义。** -理由:这 4 个键都是内核写、插件读,无并发改写需求;真正需要多插件并发改的 -(`LLMText`/`FinalText`/`ToolCalls`/`Errors`)全是强类型字段。 -不为尚不存在的通用性付 tagged union + 字符串驻留表的成本。 - -**arena 空间管理**:append-only。插件把 `FinalText` 从 10 字节改成 10KB 时 -分配新区域、更新描述符、旧区域留作垃圾;arena 用尽由内核在 stage 结束后 -(此时无插件持锁)整体压实。代价是单次 stage 内写入总量有上限。 - -### 3.4 SDK 必须封装全部复杂度 - -插件作者**永远不接触 `Slice{off,len}`**,代码与今天完全一致: - -```go -func (p *Plugin) onBeforeToolcall(ctx *sdk.StageContext) error { - ctx.Lock() - defer ctx.Unlock() - ctx.FinalText = strings.TrimSpace(ctx.FinalText) - return nil -} -``` - -SDK 内部承担:`mmap` 挂载、跨进程锁初始化、arena 分配、偏移↔Go 值转换、 -脏字段回写、进程崩溃后段清理。 - -**实现手法:插件进程内保留原生 `StageContext` 结构。** -stage 入口从共享段反序列化成本地对象 → handler 照常读写字段 → -`Lock/Unlock` 映射到跨进程锁 → handler 返回时脏字段写回共享段。 - -每次转换微秒级,换来 **17 个存量外部插件业务代码零改动**。这个交换很值。 - -### 3.5 必须留在进程内的:回调型资源 - -**"所有数据放共享内存"需要精确化**:共享内存放不了函数指针 -(地址在各进程不同,代码段布局也不同)。 - -| 类别 | 载体 | -|---|---| -| 跨进程**状态** | 共享内存 | -| 跨进程**行为** | RPC 调用回内核 | - -`Subscribe` 返回的退订闭包、`Device` 的方法集、`OutputChan` 的接收端—— -这些是"行为"不是"数据"。故准确表述为: -**所有跨进程传递的状态放共享内存,行为通过 RPC 调用回内核。** - -### 3.6 通知面:事件环 + eventfd - -**设计骨架(采纳)**:数据先落地 → 再通知 → 内核不等待。满足约束 B。 - -但单纯的信号量不够——`sem_t` 只是计数器,没有 payload、顺序、消费游标: - -```c -typedef struct { - uint64_t seq; // 全局单调序号 - uint32_t type; - Slice payload; // → arena -} EvtSlot; - -typedef struct { - _Atomic uint64_t write_seq; // 仅内核写 - uint32_t cap; // 2 的幂 - EvtSlot slots[]; -} EvtRing; - -typedef struct { // 每订阅者独立 - _Atomic uint64_t read_seq; - _Atomic uint64_t dropped; // 被覆盖丢弃计数 - uint32_t type_mask; - uint64_t last_seen; // 活性判断 -} Subscriber; -``` - -内核:写 slot → `write_seq++` → 对匹配订阅者 post,**不等待**。 -消费者:`read_seq` 追 `write_seq`,落后超 `cap` 即溢出,差值记入 `dropped` -并跳到最新——**允许丢事件但让消费者知道丢了**(与 WebUI 侧 `sync_required` 思路一致)。 - -**技术修正:用 `eventfd` 而非 `sem_t`。** - -Go 里没有轻量线程。goroutine 经 cgo 调 `sem_wait` 会**阻塞整个 OS 线程**(M 被占住), -每插件常驻一个锁死线程——又回到我们正要逃离的线程膨胀。 - -```go -efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC) -f := os.NewFile(uintptr(efd), "evtnotify") -// eventfd 是 epoll-able,os.NewFile 注册进 runtime netpoller -// f.Read() 阻塞时只 park goroutine,不占 OS 线程 -``` - -附带收益: -- 计数语义(读出累积值)天然合并突发通知——1000 个 token 事件可能只唤醒几次 -- 可与 RPC 请求在同一 select 中等待,不需两套等待机制 - -✅ **已实测通过**(见 7.2):200 个 goroutine 阻塞在 eventfd.Read 上仅增 1 个 OS 线程。 - -### 3.7 跨进程锁:倾向"锁仲裁回归内核" - -`pthread_mutex` 的 `PTHREAD_PROCESS_SHARED` + `ROBUST` 属性 Go 标准库无等价物。 -但引入它意味着**为了一个锁而保留 cgo**——与"C 整体退场"的目标冲突。 - -**方案对比**: - -| | robust pthread_mutex | 锁仲裁回内核 | -|---|---|---| -| cgo | 需要 | **不需要** | -| 崩溃处理 | 需处理 `EOWNERDEAD` + `consistent` | 进程死了内核直接释放 | -| 加锁成本 | 原子操作(纳秒) | 一次 RPC 往返(微秒) | -| 复杂度 | 高 | 低 | - -**已裁定采用后者**(实验 3+9,见 7.4/7.10):插件通过 RPC 请求"给我 stage 写锁",内核用普通 `sync.Mutex` 排队。 -stage handler 加锁频率很低(每次 stage 一两次,非每字段一次),微秒级往返可忽略。 - -**这样整个新架构可做到完全无 cgo。** - -注:事件环无需 robust 语义——`eventfd`/信号量没有所有权概念, -不存在"持锁进程死了"的死锁风险。共享内存中真正需要互斥的只有 `StageContext`。 - -### 3.8 能力对齐:外部插件可获得什么 - -| 内置独有能力 | 子进程下的等价物 | 可行 | -|---|---|---| -| `OutputChan` 消费 | 共享内存 ring + eventfd 通知 | ✅ | -| `Subscribe`/`Publish` | 事件环 + 独立游标 | ✅ | -| `RegisterChannel(Device)` | 声明式注册(caps + 工具名清单)+ 调用回传 | ✅ | -| `SetToolBlocks` | 二进制落 arena,`Slice` 描述符回传 | ✅ | -| `Config`/`Tool`/`Indexer` | 新增 RPC method(本就是数据操作) | ✅ | -| `Selftest`/`Supervisor`/`Tracker` | **不提供** | ❌ 刻意 | - -最后一行是**显式的权限决策**,而非技术限制——这正是迁移要达成的区分。 - -副产品:`localuse` 这类插件不再必须编进内核,改一行不用重编整个 homed。 - ---- - -## 四、工作量评估 - -### 4.1 分阶段拆解 - -规模标记:S = 1 人日内,M = 2-4 人日,L = 1-2 周,XL = 2 周以上。 -风险标记基于「失败时能否安全回退」。 - -#### 阶段 0:止血(不依赖任何新架构) - -> ⚠️ **本表编号已废弃**(此处仅存档当时的分组思路)。 -> 实施请用 [`plan.md` 第 11 节](../../plan.md) 的 `11.1`~`11.6`——见 0.3 的对应表。 -> 本表的 0.1/0.2/0.3 → plan 11.6;后文追加的 0.4~0.7 → plan 11.3/11.1/11.2/11.4。 - -| # | 任务 | 文件 | 规模 | 风险 | -|---|---|---|---|---| -| 0.1 | ELF 检测 `DF_1_NODELETE`,命中则标记插件"不可热重载" | `dynamic_loader_unix.go` | S | 极低 | -| 0.2 | `ReloadOne` 对此类插件直接返回"需重启",停止假装成功 | `registry.go` | S | 极低 | -| 0.3 | `plugin_install` 返回 `restart_required` 替代误导性的 `reload_required` | `pluginmgr/plugin.go` | S | 极低 | - -**价值**:零运行时开销,立刻消除"模型照着 `reload_required` 建议重载、实际白跑"的误导。 - -**后续追加(第八、九章发现,优先级高于 0.1-0.3)**: - -| # | 任务 | 现网影响 | 规模 | -|---|---|---|---| -| 0.4 | `stageContextWritable` 只回传变更字段 | ❗ 脏数据进 LLM,量级百分之几(8.6) | S | -| 0.5 | `output_send` 改同步等真实结果 | ❗ 消息发不出而模型以为成功(9.4) | M | -| 0.6 | 超时日志措辞修正 + 排查 browser 频繁超时 | ❗ 已泄漏 26 次(9.3) | S | -| 0.7 | Lua stage 快照加 `sc.RLock()` | 潜在 DATA RACE(9.1) | S | - -#### 阶段 1:能力对齐验证(不依赖子进程) - -| # | 任务 | 规模 | 风险 | -|---|---|---|---| -| 1.1 | 给 C ABI 补 `SetToolBlocks`(method id 52,走文件路径传递) | M | 低 | -| 1.2 | 用某外部插件验证多模态注入端到端可用 | S | 低 | - -**价值**:先验证"外部插件能否逼近内置能力"这一假设,不依赖任何共享内存基础设施。 -若此步就发现能力对齐有本质障碍,整个迁移的收益需重估。 - -#### 阶段 2:子进程通道原型(核心风险点) - -| # | 任务 | 文件 | 规模 | 风险 | -|---|---|---|---|---| -| 2.1 | 进程管理器:spawn/健康检查/优雅停止/崩溃重启。**可大幅参考 `clawhubadapter/sidecarProcess`**(已有 stdin/stdout + 异步 reader + `pending map[int]chan` + `notifyCh` 的成熟实现) | `internal/plugin/proc/`(新建) | L | 中 | -| 2.2 | 双向 JSON-RPC 编解码:7 个 kernel→plugin 调用 + 51 个 plugin→kernel 回调 | 同上 | M | 低 | -| 2.3 | `procPlugin` 实现 `sdk.Plugin` 接口,`Close()` 变成真 kill+wait | `dynamic_loader_unix.go` 旁 | M | 中 | -| 2.4 | `loadOne` 按 manifest `entry` 分派:`plugin.so`→cabi,`plugin.bin`→proc | `registry.go` | S | 低 | -| 2.5 | `plugin_health` 接线:进程退出码/EOF → `recordCrash`(**逻辑复用,仅换信号源**) | `plugin_health.go` 调用侧 | S | 低 | -| 2.6 | `plugindev` 新增 `tmplProcMain`:bridge 从 c-shared 导出改为 `main()` + stdio loop | `templates.go` | M | 低 | -| 2.7 | `plugindev` 构建改普通 `go build`(去 cgo,交叉编译反而简化) | `cmd_build.go` | S | 低 | -| 2.8 | `validBinaries` 加 `plugin.bin`,`.hmap` 打包/校验支持 | `pluginmgr` + `manifest` | S | 低 | -| 2.9 | 单插件单工具端到端打通(建议用 `weather`) | — | M | — | - -**关键收益**:公开 SDK 的 `PluginSDK` 方法签名全部保留,底层从 `callString(id,...)` -换成 RPC 发送——**17 个存量插件业务代码零改动,只需用新 plugindev 重编**。 - -#### 阶段 3:共享内存数据面 - -| # | 任务 | 规模 | 风险 | -|---|---|---|---| -| 3.1 | 共享段 schema + arena 分配器(append-only + 压实) | L | 中 | -| 3.2 | `StageContext` 偏移化编解码(共享段 ↔ 本地 Go 对象) | L | 中 | -| 3.3 | 锁仲裁 RPC(`stage.lock`/`stage.unlock`,内核侧 `sync.Mutex`) | M | 中 | -| 3.4 | `RunStage` 跨进程并发扇出改造(**保留并发语义,最难的一环**) | L | **高** | -| 3.5 | 段生命周期:创建/挂载/插件崩溃后清理 | M | 中 | - -**3.4 是全项目最高风险点**:必须保证多插件并发改写同一 `StageContext` 的语义 -与今天一致,否则 `sanitizer`、`multimodal` 这类改写型插件行为会静默漂移。 - -#### 阶段 4:通知面 - -| # | 任务 | 规模 | 风险 | -|---|---|---|---| -| 4.1 | `EvtRing` + `Subscriber` schema,溢出计数 | M | 低 | -| 4.2 | eventfd 通知 + Go 侧 netpoller 消费(**先做 3.6 的实测验证**) | M | 中 | -| 4.3 | `Bus.Publish` 加事件环投递(post-and-forget,**不得阻塞**) | S | **高** | -| 4.4 | 订阅者活性检测(`last_seen` 超时 → `recordCrash`) | S | 低 | -| 4.5 | 实现 `case 23/24`(今日空实现),外部插件首次获得事件能力 | M | 低 | - -**4.3 风险高**:`Bus.Publish` 在流式路径上逐 token 调用,任何阻塞都会导致 -输出卡顿。改动必须严格无锁/非阻塞,且需专门的流式压测验证。 - -#### 阶段 5:迁移与收尾 - -| # | 任务 | 规模 | 风险 | -|---|---|---|---| -| 5.1 | 17 个外部插件逐个重编译 + 回归验证 | L | 中 | -| 5.2 | 删除 `cabi/`(1096 行)与 bridge 模板(385 行) | S | 低 | -| 5.3 | 权限梯度显式化:声明式 caps + 内核侧强制 | M | 中 | -| 5.4 | 文档:插件开发指南更新、迁移说明 | M | 低 | - -### 4.2 总量估算 - -| 阶段 | 规模合计 | 可独立交付 | -|---|---|---| -| 0 止血 | ~1 人日 | ✅ 立即 | -| 1 能力对齐 | ~3 人日 | ✅ 独立 | -| 2 子进程通道 | ~3 周 | ✅ 与 cabi 共存 | -| 3 共享内存 | ~3 周 | ⚠️ 依赖阶段 2 | -| 4 通知面 | ~1.5 周 | ⚠️ 依赖阶段 3 | -| 5 迁移收尾 | ~2 周 | ⚠️ 依赖全部 | - -**合计约 10 周**(单人、含验证,不含意外)。 - -> **实验后修订:约 8-9 周**(见 7.15)。第九章新增的 Lua/Windows 路径收敛 -> 已包含在阶段 5 的迁移工作内,不额外增加工期——因为它们是**删除**而非改造。 - -### 4.3 成本对照 - -作为决策参考,三条路的真实成本: - -| 方案 | 一次性成本 | 长期代价 | 性质 | -|---|---|---|---| -| **接受重启**(仅做阶段 0) | ~1 人日 | 每次换 `.so` 中断 ~15 秒 | 有界、自愈 | -| **版本化路径** | ~3 人日 | 每次重载 +5.8 线程 +1.5MB,**永久** | 无界、单调劣化 | -| **子进程 + 共享内存** | **~8-9 周** | 常驻 **+29MB RSS**(7.6 实测,原估 50-70MB 偏高) | 有界、换来真隔离 | - -**插件更新的真实频率是每周级**(今日的密集调试是特例)。 -若唯一目标是热重载,阶段 0 的性价比远高于全量迁移。 - -**迁移正当性共 6 条**(与 9.5 同一份清单;本节讨论的热重载是第 ① 条): - -| # | 正当性 | 依据 | 有临时修复? | -|---|---|---|---| -| ① | 热重载 | 1.1(原始动机) | ✅ 11.6 可缓解(改为诚实上报) | -| ② | 插件崩溃隔离(现在一个插件 panic 能带崩 homed) | 实验 6 | ❌ 无 | -| ③ | 能力断层消除(外部插件获得事件订阅、多模态、通道注册) | 1.3 / 3.8 | ❌ 无 | -| ④ | 内置插件解耦(`localuse` 改一行不用重编 homed) | 1.3 | ❌ 无 | -| ⑤ | 修复 stage 副本 lost update(实测 35.8~36.8%,现网量级百分之几) | 8.4 / 8.6 | ⚠️ 11.3 打补丁 | -| ⑥ | 修复 cgo 固有缺陷:超时泄漏(现网 26 次)、`output_send` 假成功(现网 2 次)、Windows 退化、三套 ABI 分裂 | 第九章 | ⚠️ 11.1/11.2/11.5 打补丁 | - -**关键判断**:①⑤⑥ 有临时修复(`plan.md` 11.1~11.6),**不必等迁移**; -但那些修复是在副本模型内部打补丁,只有子进程 + 共享内存才从根上消除成因。 -**②③④ 无临时方案——它们是迁移的不可替代价值。** - -**若这六点都不成立,则不应迁移。** - -### 4.4 风险登记 - -| 风险 | 影响 | 缓解 | -|---|---|---| -| `RunStage` 并发语义漂移(3.4) | 改写型插件行为静默错误 | 机制已验证(7.9);仍需为 `sanitizer`/`multimodal` 补并发行为测试作为基线 | -| `Bus.Publish` 引入阻塞(4.3) | 流式输出卡顿 | 专项流式压测;投递路径禁用任何锁 | -| ~~eventfd 未走 netpoller~~ | — | ✅ **已排除**(7.2 实测 +1 线程) | -| 17 进程常驻开销 | 实测仅 +29MB RSS(7.6) | ✅ **风险关闭** | -| arena 单 stage 写入上限 | 大写入插件失败 | 明确上限并在 SDK 层报错,而非静默截断 | -| Windows 无验证环境(9.2) | 迁移后 Windows 行为未知 | 需借测试机;当前 Windows 路径本就严重退化,风险不增 | -| Lua 插件迁移路径未设计(9.1) | Lua 插件如何跑在子进程内 | 可保留进程内 Lua VM(无 cgo 问题)或独立 Lua 宿主进程;待设计 | -| 存量插件回归 | 17 个插件行为变化 | 阶段 2.4 的 entry 分派让两种插件**共存**,可逐个迁移、随时回退 | - -### 4.5 推进原则 - -**双通道共存是整个计划可行的前提。** `registry.go` 按 manifest `entry` 分派 -(阶段 2.4)意味着 `.so` 与 `.bin` 插件可同时运行: - -``` -1. 打通 proc 通道,用 weather 验证 -2. 逐个迁移,其余 .so 继续跑 -3. 全部迁完再删 cabi 路径 -``` - -任何一步失败都能回退,不会出现"改到一半 homed 起不来"。 - ---- - -## 五、待定决策 - -> ⚠️ 本章为第七章实验前的初始状态。**最新裁定见 7.14,正当性清单更新见 9.5。** - -以下需明确后才能进入实施: - -1. **是否全量迁移?** ⏳ **仍待决定**。若只为热重载,阶段 0 即够(1 人日 vs 8-9 周)。 - 全量迁移的理由已从 3 条扩充到 **6 条**(完整对照表见 4.3)。 - 其中 ②崩溃隔离 / ③能力对齐 / ④内置解耦 **无临时替代方案**; - ①热重载 / ⑤lost update / ⑥cgo 缺陷 可先用 `plan.md` 11.1~11.6 打补丁。 - -2. **跨进程锁选型**:~~锁仲裁回内核 vs robust pthread_mutex~~ - ✅ **已裁定:锁仲裁回内核**(实验 3 测得 19.4 µs/次;实验 9 证明持锁进程崩溃可自愈, - 无需 `EOWNERDEAD` 处理)。**新架构完全无 cgo。** - -3. **`Extra` 处置**:✅ **维持原建议**——4 键提升为共享段具名字段,`Extra` 本身留 RPC 副本。 - 第八章的发现进一步支持此选择:外部插件**根本拿不到 `Extra`**(不在下发的 10 个字段内), - 故通用 tagged union 是为不存在的需求付成本。 - -4. **权限梯度的显式形式**:⏳ 待设计(不阻塞阶段 0-2)。 - **前提已澄清**:内置插件的高权限是**刻意的设计决策**,不是"自己人所以安全"。 - 当前实现把「应有的权限梯度」与「C ABI 的表达能力天花板」混在了一起—— - 外部插件拿不到 `OutputChan` 是技术限制(Go channel 过不了 C ABI), - 而非权限决定(证据:`case 23/24` 事件订阅是空实现,是"给不了"而非"不给")。 - 迁移目标是让梯度从**技术意外**变成**显式声明并强制的策略**,而非消除梯度。 - `Selftest`/`Supervisor`/`Tracker` 确认永不对外(见 3.8 能力对齐表最后一行)。 - ---- - -## 六、已验证事实清单 - -本文档结论的实证基础,便于后续复核: - -| 结论 | 验证方式 | 结果 | -|---|---|---| -| Go c-shared 带 `DF_1_NODELETE` | `readelf -d plugin.so` | `FLAGS_1: NODELETE` | -| `dlclose` 对其是 no-op | `/proc/self/maps` 段数 | 5 → 5(不归零) | -| 纯 C `.so` 可真正卸载 | 同上 | 5 → 0,换代码生效 | -| C shim 中间层无法绕过 | 经 shim dlopen/dlclose Go 库 | 5 → 5,仍未卸载 | -| Go c-shared 无 SONAME | `readelf -d \| grep SONAME` | 无(换路径不会被去重) | -| 版本化路径有效 | 两个内容不同的 Go c-shared | handle 不同,ver=v2 生效 | -| 每次重载泄漏 ~5.8 线程 | 30 次连续 dlopen,读 `/proc/self/task` | +168 线程 / +46MB RSS | -| `GOMAXPROCS=1` 缓解有限 | 同上 | 降至 +4.0 线程/次 | -| stage 是并发扇出 | 读 `stages.go:124` | `go func` + `wg.Wait` | -| `Bus.Publish` 同步阻塞 | 读 `bus.go:56` | 内联 `safeCall` 循环 | -| 流式逐 token 发事件 | `process.go:388/470/479` | `EventContentDelta` | -| 外部插件无事件能力 | 读 `loader.go` case 23/24 | 空实现 `return 0` | -| `Extra` 仅 4 个键 | 全量 grep 使用点 | eventloop/process/distill 各处 | -| toolcall payload 很小 | 96 样本统计 | 中位 93 B,最大 134 B | -| 内置/外部方法数 | 对比两个 `PluginSDK` | 28 vs 36,差在活对象 | -| 生命周期机制已完善 | 读 `plugin_health.go`/`toolcall.go` | 3 崩溃/5 分钟、60 秒超时、30 秒冷却 | - -**第七章新增(新架构可行性,11 项)** - -| 结论 | 验证方式 | 结果 | -|---|---|---| -| eventfd 走 netpoller | 200 goroutine 阻塞 `Read`,读 `/proc/self/task` | +1 线程 | -| 跨进程偏移解引用 | 父子进程 mmap 基址对比 | 基址不同,偏移仍正确 | -| memfd + fd 继承可建共享段 | `MemfdCreate` + `ExtraFiles` | 无需 `/dev/shm` 命名与清理 | -| 锁仲裁 RPC 成本 | 20000 次 stdio 往返 | 19.4 µs/次 | -| post-and-forget 解耦流式 | 5000 token + 20µs 慢消费者 | 5.07s → 2.29ms(2218x) | -| 17 子进程常驻开销 | 读 `smaps_rollup` PSS | 29.1MB RSS / 12.9MB PSS | -| 子进程线程数低于单进程 | 对照 homed | 84 vs 108 | -| 崩溃隔离 | 子进程 panic | 退出码 2,EOF 2.5ms,宿主存活 | -| 子进程热重载 | 同路径替换二进制 | v1→v2 立即生效 | -| 跨进程并发改写 StageContext | 5 进程 × 300 轮 append | 358 字符 = 358 长度,零丢失 | -| 持锁进程崩溃自愈 | 持锁 panic 后其他进程申请 | 正常获得,无死锁 | -| 二进制零拷贝 | 100KB/1MB/5MB 对比 | 18-22x,体积 −100% | -| 工具调用 RPC 延迟 | 10000 次真实 payload | p50 19.5 µs | - -**第八章新增(现有实现的真实语义)** - -| 结论 | 验证方式 | 结果 | -|---|---|---| -| 外部插件 stage 是副本模型 | 读 `loader.go:411-439` + `templates.go:768-787` | 快照→新对象→写回 | -| 外部插件 `ctx.Lock()` 是空操作 | 同上链路推演 | 锁的是副本自己的 mu | -| 外部插件字段被裁剪 | 对比下发字段与 `StageContext` | 16 字段只下发 10 | -| **副本模型 lost update 率** | 复刻链路,5 插件 × 2000 轮 | ❗ **36.8%**(内置 0%) | -| `stageContextWritable` 无条件回传 | 读 `templates.go:762` | 非空即回传,未改也传 | -| **现网 sanitizer+weather 冲突** | 复刻场景 3000 轮 | ❗ **1.6~4.3%** 脏数据进 LLM | -| 现网两插件均在运行 | `plugin.json` + `disabled_plugins` + 日志 | 均 8/15 部署,未禁用 | -| `StageScopeOwnTools` 不减并发 | 读 `sdk/plugin.go:304` | SDK 层包装,仍并发调度 | - -**第九章新增(补盲)** - -| 结论 | 验证方式 | 结果 | -|---|---|---| -| 插件类型有四种 | `validBinaries` + `internal/plugin/*.go` | so/dylib/dll + lua + SKILL.md | -| Lua stage 快照无读锁 | 读 `lua_plugin.go:726` | 无 `sc.RLock()`,DATA RACE | -| Windows DLL 只下发 3 字段 | 读 `dynamic_dll_windows.go:231` | 且**完全无写回** | -| **cgo 超时不可中断** | 纯 C 死循环 `.so`,20 次卡死 | ❗ 泄漏 20 goroutine / 18 线程 | -| 子进程 Kill 后零泄漏 | 同实验 B 组 | OS 回收全部资源 | -| **现网已发生工具超时** | 14 天 journal 统计 | ❗ **26 次**(browser 占 22) | -| **`output_send` 永远返回成功** | 读 `loader.go:458-470` + `output.go:65-70` | ❗ 返回 `{status:queued}` | -| **现网已发生发送失败** | 7 天 journal 统计 | ❗ **2 次**,模型收到"已发送" | -| homed 主 heap 2.36GB 真实驻留 | `/proc/PID/status` + maps 分析 | 与插件无关,独立问题 | - ---- - -## 七、前期可行性实验(已执行) - -本章记录第五章「待定决策」与第四章风险项的**实测结论**。 -所有实验代码位于 `/tmp/feas/`,环境 go1.25.12 linux/amd64,本机 192.168.2.60。 - -### 7.1 结论总览 - -| # | 待验证项 | 原假设 | 实测结论 | -|---|---|---|---| -| 1 | eventfd 是否走 netpoller | ⚠️ 待实测 | ✅ **成立**,200 等待者仅 +1 线程 | -| 2 | 跨进程 eventfd + 偏移解引用 | 推理 | ✅ **成立**,不同 mmap 基址正确解引用 | -| 3 | 锁仲裁 RPC 往返成本 | 「微秒级」 | ✅ **19.4 µs/次** | -| 4 | post-and-forget 解耦流式 | 推理 | ✅ **2218x** 加速 | -| 5 | 17 子进程常驻开销 | 估 50-70MB | ✅ **实际 29MB RSS / 12.9MB PSS**,远优于估计 | -| 6 | 崩溃隔离 + 退出码信号源 | 推理 | ✅ 退出码 2,EOF 2.5ms 感知,宿主存活 | -| 7 | 子进程热重载 | 推理 | ✅ 同路径替换即生效,无需版本化路径 | -| 8 | **跨进程并发改写 StageContext** | ⚠️ 最高风险 | ✅ **5 插件 × 300 轮无丢失无撕裂** | -| 9 | 持锁进程崩溃自愈 | 需 robust mutex? | ✅ **不需要**,内核 Wait/EOF 强制释放 | -| 10 | 二进制零拷贝收益 | 推理 | ✅ **18-22x** 加速,体积省 100% | -| 11 | 工具调用 RPC 延迟 | 「噪声里」 | ✅ p50 **19.5 µs**,占 LLM 往返 0.00065% | - -**本章 11 项全部通过**(另有第一章 dlclose 组 3 项、第九章 cgo 组 2 项,文档合计 18 项可复跑实验)。 -**第五章的 4 个待定决策中,2、3 已由实验裁定。** - -### 7.2 实验 1:eventfd 走 netpoller(原文档标记 ⚠️ 待实测) - -``` -基线线程数: 5 (GOMAXPROCS=12) -200 个 goroutine 阻塞在 eventfd.Read 后: - 线程数 = 6 (增长 1) - ✅ 走 netpoller:线程未随等待者数量增长 -唤醒数 = 200/200 -``` - -**结论**:`os.NewFile(eventfd)` 确实注册进 runtime netpoller,`Read` 只 park goroutine。 -200 个等待者仅增 1 个 OS 线程,验证了 3.6 节的设计前提。 - -反面对照即 `sem_wait`:经 cgo 会阻塞整个 M,200 等待者 = 200 锁死线程。 - -### 7.3 实验 2:跨进程 eventfd + 偏移解引用 - -``` -PARENT: mmap 基址 = 0x7f0137a36000 -CHILD: mmap 基址 = 0x7f1eb3437000 ← 不同虚拟地址 -PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:28, seq:42} -PARENT: post 耗时 10.85µs ← post-and-forget -CHILD: 被 eventfd 唤醒, 计数=1 -CHILD: 偏移解引用 off=1024 len=28 seq=42 → "hello-from-parent-via-offset" -PARENT: 读到子进程回写 → "CHILD-ACK" ✅ 双向可见 -``` - -**两个关键点得到验证**: - -1. 父子进程 mmap 到**完全不同的虚拟地址**(`0x7f0137a36000` vs `0x7f1eb3437000`), - 相对偏移 `{off,len}` 仍正确解引用——这正是「偏移替代指针」的核心论据 -2. `memfd_create` + `ExtraFiles` fd 继承即可建立共享段,**无需 `/dev/shm` 命名与清理** - -### 7.4 实验 3:锁仲裁 RPC 成本(裁定决策 2) - -``` -20000 次 stage.lock RPC 往返 用时 388ms, 均摊 19.40 µs/次 -``` - -**裁定:采用「锁仲裁回归内核」,放弃 robust pthread_mutex。** - -19.4 µs 相对 stage handler 的实际工作量(LLM 往返 2-8 秒)完全可忽略。 -换来:零 cgo、无 `EOWNERDEAD` 处理、崩溃自愈(见 7.10)。 - -### 7.5 实验 4:post-and-forget 解耦流式输出 - -模拟 5000 token 流式发布 + 20µs 慢消费者: - -``` -A 同步 Publish (现状): 5000 token 耗时 5.07s 均摊 1014.5 µs/token -B 环+eventfd post: 5000 token 耗时 2.29ms 均摊 0.46 µs/token -加速比 2218.6x 丢弃事件 0 -``` - -**验证了约束 B 的严重性与解法有效性**。现状下一个 20µs 的慢订阅者 -就能让 5000 token 的流式输出多花 5 秒;改为写环 + post 后降到 2.3ms。 - -### 7.6 实验 5:17 子进程常驻开销(修正文档估计) - -``` -存活进程 17/17 -合计: PSS=12.9 MB RSS=29.1 MB 线程=84 -均摊: PSS=0.76 MB RSS=1.71 MB 线程=4.9 -插件二进制大小: 2.68 MB -``` - -**原估计 50-70MB 偏高,实际 29MB RSS / 12.9MB PSS。** -PSS 远低于 RSS 说明 Go runtime 的只读代码页在进程间**共享**了。 - -意外发现的对照数据: - -``` -homed 当前(单进程 + 15 个已映射 .so): RSS=2344 MB 线程=108 -``` - -线程数 108 **高于** 17 个独立子进程的 84——因为每个 c-shared 映像 -都带自己的 `sysmon`/GC worker,塞在同一进程里并不省线程。 - -### 7.7 实验 6:崩溃隔离 - -``` -正常调用 → map[ok:true] -发送 boom(插件内 panic)... -调用侧感知: EOF (耗时 2.515ms) -进程退出码 = 2 ← panic 的标准退出码 -宿主进程仍存活 ✅ 崩溃已隔离 -``` - -**`plugin_health.go` 的接线方案得到验证**:`exec.ExitError.ExitCode()` 与 -stdio EOF 都能在毫秒级感知,直接喂给现有 `recordCrash(plugin)` 即可, -3 次/5 分钟窗口、30 秒冷却、`pendingReloads` 全部逻辑不动。 - -对照当前 `.so` 模型:bridge 兜不住的 panic 会带崩整个 homed。 - -### 7.8 实验 7:热重载(迁移的原始目标) - -``` -1) 首次启动插件 → version = v1.0.0 -2) 替换二进制为 v2.0.0(同路径) -3) 重启插件进程 → version = v2.0.0 -✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏 -``` - -**第 1.1/1.2 节的全部问题在子进程模型下自动消失**: -不需要 `.load/plugin-.so`、不需要重载配额、不需要 ELF 标记检测。 - -### 7.9 实验 8:跨进程并发改写 StageContext(最高风险点 3.4) - -5 个独立进程各 300 轮,通过 RPC 申请内核侧锁,在共享段 append-only arena -上读-改-写同一个 `final_text`: - -``` -最终 final_text 长度 = 358 -各插件写入次数: map[A:76 B:70 C:73 D:73 E:66] -总字符 = 358, 长度 = 358 → 一致 ✅ 无丢失/无撕裂 -RPC 锁操作 = 726 次, 总耗时 213ms -``` - -**总字符数严格等于最终长度**,证明: -- 没有写丢失(lost update) -- 没有撕裂读(torn read) -- 5 个进程的修改都被保留且顺序一致 - -写入次数少于 5×300 是 arena 64KB 上限所致(append-only 未实现压实), -符合 3.3 节设计——**印证了 arena 需要压实机制**,且上限应在 SDK 层显式报错。 - -**风险 3.4 的核心机制得到验证**,但仍需注意:本实验验证的是**机制正确性**, -不能替代 `sanitizer`/`multimodal` 的**行为回归测试**(4.4 节风险登记仍然有效)。 - -### 7.10 实验 9:持锁进程崩溃自愈(裁定决策 2 的第二半) - -``` -1) 插件 X 拿锁后 panic: - [X] 获得锁 - [X] 进程死亡(exit status 1),内核强制释放其持有的锁 ← 自愈 -2) 插件 Y 随后申请同一把锁: - [Y] 获得锁 - [Y] 释放锁 -✅ Y 正常获得并释放锁 —— 无死锁 -``` - -**这条彻底排除了 robust pthread_mutex 的必要性**: -锁的所有权在内核进程,插件死亡由 `cmd.Wait()` / stdio EOF 检测, -内核代为释放。不存在「持锁者死亡导致全局死锁」的场景。 - -**故 3.7 节的选型确定:锁仲裁回归内核,整个架构零 cgo。** - -### 7.11 实验 10:二进制零拷贝(未来多媒体能力) - -| payload | JSON+base64 | 共享内存 | 加速 | 体积 | -|---|---|---|---|---| -| 100KB | 1.21 ms,136587 B (+33%) | 62.7 µs,8 B | 19x | −100% | -| 1MB | 12.41 ms,1398155 B (+33%) | 554.7 µs,8 B | 22x | −100% | -| 5MB | 49.30 ms,6990559 B (+33%) | 2.72 ms,8 B | 18x | −100% | - -**共享内存对二进制 payload 的价值确认**:传输体积从 +33% 降为 8 字节描述符, -处理耗时降低约 20 倍。这是 2.5 节「共享内存价值不在省序列化」的**唯一例外**—— -对大块二进制它恰恰就是省序列化。 - -### 7.12 实验 11:工具调用 RPC 延迟 - -用实测的真实 payload 形态(`{"city":"hangzhou","days":3,...}`,约 93 B)10000 次: - -``` -p50 = 19.497µs p90 = 25.447µs p99 = 44.603µs max = 2.256ms -对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ 0.00065% -``` - -**验证 2.5 节判断**:控制面用 JSON-RPC 完全够用,无需为它引入共享内存。 - -### 7.13 意外发现:homed 当前内存异常(独立问题) - -实验 5 的对照测量暴露了一个与迁移无关但值得记录的问题: - -``` -homed RSS = 2390432 kB (2.34 GB) - RssAnon = 2354764 kB ← 真实驻留的匿名内存 - RssFile = 35668 kB - VmSize = 26016540 kB (24.8 GB 虚拟) - -匿名映射构成: - 2420.0 MB × 1 = 2.36 GB ← 主 homed 的 Go heap(真实驻留) - 512.0 MB × 15 = 7.50 GB ← 15 个插件各自的 heap arena(虚拟预留) -``` - -两点观察: - -1. **512MB × 15**:每个 Go c-shared 插件各自 mmap 独立 heap arena, - 彼此不可见、GC 各自为政。这是虚拟预留(不占物理内存), - 但说明当前架构下**插件间内存无法协同回收**——子进程模型下反而更清晰。 - -2. **2.36 GB 真实驻留在主 homed 的 heap** 上,与插件无关。 - 这是独立的内存增长问题(可能是 chat history / context 累积), - **不影响迁移评估,但应单独排查**。 - -### 7.14 实验后更新的决策状态 - -第五章 4 个待定决策的当前状态: - -| # | 决策 | 状态 | -|---|---|---| -| 1 | 是否全量迁移 | ⏳ **待用户决定**(技术可行性已全部验证) | -| 2 | 跨进程锁选型 | ✅ **已裁定**:锁仲裁回内核,零 cgo(实验 3+9) | -| 3 | `Extra` 处置 | ✅ **维持原建议**:4 键提升为具名字段(实验 2 验证偏移化可行) | -| 4 | 权限梯度显式形式 | ⏳ 待设计(不阻塞阶段 0-2) | - -### 7.15 工作量评估的修订 - -实验结果对第四章的影响: - -| 项 | 原评估 | 修订 | -|---|---|---| -| 3.7 跨进程锁 | 两方案待选,可能需 cgo | **确定零 cgo**,规模 M→S | -| 4.2 eventfd 消费 | ⚠️ 待实测,风险中 | **验证通过**,风险中→低 | -| 3.4 并发扇出 | 风险**高** | 机制已验证,风险高→**中**(行为回归仍需做) | -| 常驻开销 | 估 50-70MB | **实际 29MB**,风险项可关闭 | -| 4.3 Publish 改造 | 风险高 | 收益已量化(2218x),风险高→中 | - -**总量估计从约 10 周下调至约 8-9 周**(3.7 简化 + 3.4/4.2 风险降低)。 - -不变的部分:阶段 5 的 17 个插件逐个回归验证仍是 ~2 周,无法压缩。 - ---- - -## 八、代码检查与实地实验(第二轮) - -第七章验证的是**新架构可行性**;本章检查**现有实现的真实语义**, -并发现了一个先前评估建立在错误前提上的关键事实。 - -### 8.1 核心更正:外部插件从未共享过 StageContext - -第 2.4 节把「stage 并发扇出改写同一对象」列为**约束 A**,并据此论证共享内存的必要性。 -代码检查表明:**该语义只对内置插件成立,外部插件一直是「快照-副本-写回」模型。** - -完整链路(`cabi/loader.go:411-439` + `templates.go:768-787`): - -``` -① 内核 case 2 handler - sc.RLock() → 快照 7~10 个字段为 JSON → sc.RUnlock() -② 跨 ABI 传字符串 -③ go_invoke_stage - sc := &sdk.StageContext{} ← 插件进程内【全新对象】 - fillStageContext(sc, ctxJSON) -④ 插件 handler 执行 - ctx.Lock() 锁的是这个新对象的 mu ← 无竞争者,纯空转 -⑤ stageContextWritable(sc) → Marshal 回传 -⑥ applyStageResult(sc, result) - sc.Lock() → 逐字段写回内核 sc → sc.Unlock() -``` - -**这是为方便插件加载而采取的无奈之举**(C ABI 无法传递 Go 对象引用), -但它带来三个先前未被识别的后果。 - -### 8.2 后果一:外部插件的 `ctx.Lock()` 是空操作 - -`sanitizer` 的 stage handler(`example/sanitizer/plugin.go:52-90`): - -```go -s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error { - ctx.Lock() // ← 锁的是副本自己的 mu - defer ctx.Unlock() // 插件进程内无其他 goroutine 竞争 - for i, tr := range ctx.ToolResults { ... } -}) -``` - -插件作者按文档正确加锁,但该锁**不提供任何跨插件互斥**。 -锁语义在 ABI 边界上静默失效——插件作者无从察觉。 - -### 8.3 后果二:字段可见性被静默裁剪 - -内核只快照 7 个字段 + 3 个条件字段(`loader.go:412-428`): - -``` -raw_message user_id group_id phase llm_text final_text no_memory -+ response(非 nil) tool_calls(非空) tool_results(非空) -``` - -`StageContext` 实际有 16 个字段。**外部插件永远看不到**: - -``` -ContextMsgs ReasoningContent TokenUsage Memory Extra Errors -``` - -这解释了 3.3 节的一个疑问——`Extra` 只有 4 个键且全由内核读写, -因为**外部插件根本拿不到它**。 - -### 8.4 后果三(严重):副本模型存在真实的 lost update - -`read-modify-write` 在「快照 → 副本修改 → 写回」链路上**非原子**。 -快照与写回之间的窗口使并发 handler 互相覆盖。 - -**实验 12**(`/tmp/lostupdate/main.go`,精确复刻上述链路,5 插件并发追加标记 × 2000 轮): - -``` - 内置插件(共享同一对象) 0/2000 轮出现修改丢失 (0.0%) - 外部插件(快照-副本-写回) 735/2000 轮出现修改丢失 (36.8%) -``` - -**内置模型零丢失,外部副本模型丢失率 36.8%。** - -### 8.5 现网影响面核查 - -各 stage 的实际注册者: - -| Stage | 注册者 | 风险 | -|---|---|---| -| `PreAction` | memo(外部) + webui(内置) | ⚡ 外部写回可能覆盖内置修改 | -| `BeforeToolcall` | qq(外部/own_tools) + webui(内置) + cmd(内置) | ⚡ 同上 | -| `AfterToolcall` | **sanitizer(外部/Global) + weather(外部/own_tools)** | ⚠️ **两个外部插件同 stage** | -| `OnInput` | sanitizer(外部) | — | -| `PostAction` | sanitizer(外部) | — | -| `BeforeOutput` | webui(内置) | — | - -**关键点在 `stageContextWritable`(`templates.go:762`)**: - -```go -if len(sc.ToolResults) > 0 { - m["tool_results"] = sc.ToolResults // ← 无条件回传 -} -``` - -只要 `ToolResults` 非空就回传——**即使插件根本没修改它**。 -`weather` 的 handler 只做只读打印,但仍会把**它收到的快照版本**写回内核。 - -### 8.6 实验 13:现网场景复刻(确认脏数据进 LLM) - -精确复刻「模型调用 `weather_query` 时 sanitizer + weather 并发跑 `AfterToolcall`」 -(`/tmp/lostupdate/real.go`,3000 轮): - -``` -3000 轮中 47 轮清洗结果被覆盖 (1.6%) -⚠️ weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果 -→ 脏数据(ANSI 转义)进入 LLM 上下文 -``` - -> ⚠️ **该比率随机器负载波动**:复跑观测到 **1.6% ~ 4.3%** 区间 -> (取决于两个插件 handler 的实际执行耗时比)。 -> 应理解为「量级在百分之几」而非精确常数。 - -现网条件已确认: - -``` -sanitizer v0.1.0 entry=plugin.so 已部署 2026-08-15 未禁用 -weather v1.0.0 entry=plugin.so 已部署 2026-08-15 未禁用 -运行日志: [sanitizer] stage OnInput/AfterToolcall/PostAction registered -disabled_plugins: 无 -``` - -**这是一个现存的、可复现的、正在生产环境发生的数据污染缺陷**, -概率量级为百分之几(复跑区间 1.6~4.3%,取决于两个插件的实际执行耗时比)。 - -### 8.7 对迁移论证的影响 - -**先前的论证方向被推翻,但结论被强化。** - -| | 先前认识 | 实际情况 | -|---|---|---| -| 共享内存的作用 | **保持**现有并发协作语义 | **修复**副本模型的 lost update | -| 风险 3.4 的性质 | 高风险:可能破坏正确行为 | 中风险:**当前行为本就是错的** | -| 迁移的正当性 | 热重载 + 崩溃隔离 + 能力对齐 | **再加一条:修复现存数据污染** | - -原先担心「跨进程改造会让 `sanitizer`/`multimodal` 行为漂移」—— -实际上外部插件**早已在副本模型下运行**,漂移已经发生了。 -共享内存 + 内核锁仲裁(实验 8 验证零丢失)是**修复**而非**风险**。 - -### 8.8 派生结论:一个可立即修复的缺陷 - -8.5 的根因(`stageContextWritable` 无条件回传未修改字段)**不需要等待迁移**。 - -最小修复:在 `go_invoke_stage` 里记录调用前的字段快照,回传时**只带真正变更的字段**: - -```go -before := stageContextWritable(sc) // 调用 handler 前 -if err := h(sc); err != nil { ... } -after := stageContextWritable(sc) -diff := changedFieldsOnly(before, after) // 只回传 diff -``` - -这能把 8.6 的污染率降到 0(weather 没改 `tool_results`,就不回传它), -且不改变任何现有插件的代码。 - -**规模 S(约 0.5 人日),风险低,收益立即可见。** -建议加入阶段 0,与三个止血项一并做。 - -### 8.9 更新后的阶段 0(编号已废弃,见 0.3) - -| # | 任务 | 文件 | 规模 | -|---|---|---|---| -| 0.1 | ELF 检测 `DF_1_NODELETE` → 标记不可热重载 | `dynamic_loader_unix.go` | S | -| 0.2 | `ReloadOne` 返回"需重启",停止假装成功 | `registry.go` | S | -| 0.3 | `plugin_install` 改 `restart_required` | `pluginmgr/plugin.go` | S | -| **0.4** | **`stageContextWritable` 只回传变更字段(修复 8.6 的数据污染)** | `templates.go` + 重编全部外部插件 | S | - -⚠️ 0.4(= `plan.md` 11.3)需重新编译并安装全部 17 个外部插件(bridge 模板变更), -须走 `plugindev` 正规工具链 + `plugin_install` 内核接口。 - -> **本节编号已废弃**,实施请用 `plan.md` 的 11.1~11.6(对应关系见 0.3)。 - -### 8.10 第二轮实验汇总 - -| # | 检查/实验 | 结论 | -|---|---|---| -| 12 | 副本模型 lost update 率 | ❗ **36.8%**(内置模型 0%) | -| 13 | 现网 sanitizer+weather 冲突 | ❗ **1.6~4.3%** 脏数据进 LLM | -| — | 外部插件 stage 语义 | ❗ 一直是副本,非共享 | -| — | 外部插件 `ctx.Lock()` | ❗ 空操作,锁语义静默失效 | -| — | 外部插件字段可见性 | ❗ 16 字段中 6 个不可见 | -| — | `stageContextWritable` | ❗ 无条件回传未修改字段(8.6 根因) | -| — | `StageScopeOwnTools` 过滤 | ✅ 在 SDK 层包装(`sdk/plugin.go:304`),不减少并发 | - ---- - -## 九、补盲分析(第三轮) - -第八章发现外部插件 stage 是副本模型;本章继续排查此前评估**完全未覆盖**的区域, -新发现 4 类问题,其中 2 项正在生产环境造成实际故障。 - -### 9.1 盲区一:插件类型不止两种,Lua 路径存在数据竞争 - -此前全程只讨论 native(内置)与 cabi(`.so`)两类。实际有**四种加载路径**: - -``` -internal/plugin/lua_plugin.go 978 行 ← 完全未评估 -internal/plugin/dynamic_lua.go 24 行 -internal/plugin/dynamic_dll_windows.go ← 完全未评估 -internal/plugin/dynamic_loader_unix.go -``` - -`validBinaries` 印证了这一点: - -```go -var validBinaries = map[string]bool{ - "plugin.so": true, "plugin.dylib": true, "plugin.dll": true, - "main.lua": true, "SKILL.md": true, // ← Lua 与 Skill -} -``` - -**Lua stage handler 同为副本模型,但缺少读锁保护**: - -| 路径 | 快照时是否持锁 | -|---|---| -| cabi(`loader.go:412`) | ✅ `sc.RLock()` → 快照 → `sc.RUnlock()` | -| Lua(`lua_plugin.go:726`) | ❌ **直接读 `sc.RawMessage` 等字段,无锁** | - -```go -func makeStageHandler(...) sdk.StageHandler { - return func(sc *sdk.StageContext) error { - plg.mu.Lock() // ← 锁的是 Lua VM,不是 sc - defer plg.mu.Unlock() - ctx := map[string]interface{}{ - "raw_message": sc.RawMessage, // ← 无 sc.RLock() - "llm_text": sc.LLMText, - ... - } -``` - -由于 `RunStage` 是并发扇出,这与其他 handler 的 `sc.Lock()` 构成**数据竞争**: -Go race detector 会报 DATA RACE,string header 并发读写理论上可读到撕裂值。 - -**现网影响**:当前未部署 Lua 插件(`find` 无 `main.lua`),故暂未触发。 -但这是一个**已存在的缺陷**,一旦部署 Lua 插件 + 任意其他 stage 插件即可触发。 - -### 9.2 盲区二:Windows DLL 路径能力严重退化 - -`dynamic_dll_windows.go:227-245` 的 stage 实现: - -```go -ctxJSON, _ := json.Marshal(map[string]interface{}{ - "raw_message": sc.RawMessage, - "user_id": sc.UserID, - "phase": string(sc.Phase), -}) -syscall.SyscallN(p.invokeStage, p.handle, ...) -return nil // ← 无 resultOut,无 applyStageResult -``` - -三条路径的字段可见性对比: - -| 路径 | 下发字段数 | 写回 | -|---|---|---| -| Linux cabi | 10(7 固定 + 3 条件) | ✅ `applyStageResult` | -| Lua | 10 | ✅ `applyLuaStageResult` | -| **Windows DLL** | **3** | ❌ **完全没有** | - -**后果**:`sanitizer` 这类改写型插件在 Windows 上**静默失效**—— -handler 正常执行、日志正常打印,但所有修改被丢弃。 -且看不到 `llm_text`/`final_text`/`tool_calls`/`tool_results`, -意味着 Windows 上的外部插件基本无法做任何有意义的 stage 处理。 - -**这是跨平台一致性的严重缺口**,且没有任何运行时警告。 - -### 9.3 盲区三(严重):cgo 调用不可中断,工具超时永久泄漏 - -`toolcall.go:32-43` 的超时保护: - -```go -done := make(chan string, 1) -go func() { done <- a.executeToolCallInner(tc) }() -select { -case result := <-done: return result -case <-time.After(60 * time.Second): - return "工具执行超时(60秒),已取消" // ← "已取消"是不准确的 -} -``` - -**`select` 超时只是让调用方返回,goroutine 仍卡在 `C.call_invoke_tool` 里。 -cgo 调用不可被 Go runtime 抢占或取消**——C 函数不返回,该 M(OS 线程)永久占用。 - -**实验 14**(`/tmp/feas/hang/`,纯 C 死循环 `.so`,20 次卡死调用): - -``` -基线 threads=6 goroutines=1 - 5 次卡死调用后: goroutines= 6 threads= 9 (+3) - 10 次卡死调用后: goroutines=11 threads=14 (+8) - 15 次卡死调用后: goroutines=16 threads=19 (+13) - 20 次卡死调用后: goroutines=21 threads=24 (+18) - -结论: 20 次超时 → 泄漏 20 goroutine, 18 OS 线程 -``` - -**线性泄漏,永不回收。** 对照子进程模型(同实验 B 组): -`cmd.Process.Kill()` 后 OS 回收全部资源,**零泄漏**。 - -**现网已在发生**(近 14 天日志): - -``` - 9 tool browser_screenshot timed out after 60s - 5 tool browser_render - 2 tool output_send__webui - 2 tool browser_type - 2 tool browser_start - 2 tool browser_click - 1 tool cmd_run / browser_html / browser_fetch - ───── - 26 次超时 → 推算泄漏约 26 goroutine + 20+ OS 线程 -``` - -`browser` 插件是主要来源(22/26)。这部分解释了 homed 的 108 线程 -——虽然本次运行 9.5 小时内无超时(`futex_wait_queue` 100 个属正常 Go 调度), -但历史进程(如 `homed[1063615]`、`homed[2609279]`)在超时后必然累积了泄漏。 - -**这是「60 秒超时保护」的语义谎言**:日志说"已取消",实际什么都没取消。 - -### 9.4 盲区四(严重):output_send 永远返回成功,模型无法感知发送失败 - -`cabi/loader.go:458-470` 的注释直接点明了原因: - -```go -s.RegisterOutputChannel(chName, n1, a2, chDef, func(args ...) (interface{}, error) { - // Output is async: return immediately, send in background - // to avoid nested cgo calls (cgo within cgo can crash) - go func() { - if err := pluginInvokeOutput(pid, chName, string(argsJSON)); err != nil { - log.Printf("[dispatch] async output %s/%s failed: %v", ...) // ← 仅日志 - } - }() - return map[string]interface{}{"status": "queued"}, nil // ← 立即返回"成功" -}) -``` - -**「cgo 嵌套会崩」这个 C ABI 限制,逼出了 fire-and-forget 设计。** - -完整链路(`output.go:65-70`): - -``` -模型调用 output_send__qq - → dev.Execute("output", args) 返回 {status: queued}, err=nil - → 模型看到: 「已通过 [qq] 通道发送: map[status:queued]」 ← 成功 - → 数十毫秒后 goroutine 里真实发送失败,仅写日志 - → 模型不知道、不重试;用户收不到消息 -``` - -**现网证据**(近 7 天): - -``` -成功 44 次,失败 2 次 - -Aug 30 15:10:51 [dispatch] async output qq/qq failed: - invoke_output qq: meta 中需要 group_id 或 user_id 字段 -``` - -那一次模型收到的是「已发送」,实际消息从未送达。 - -**这与此前的排查直接相关**:之前诊断「qq 渠道回复丢失」时修复了系统提示词 -(强调 qq 是异步通道、必须用 `output_send`),但**未发现 `output_send` 本身 -永远返回成功**。模型即使正确调用了工具,也无法知道是否真的送达。 - -### 9.5 子进程模型对这四项的修复能力 - -| 盲区 | 根因 | 子进程模型 | -|---|---|---| -| 9.1 Lua 无读锁 | 实现疏漏(非架构) | 需单独修;统一走 RPC 后天然有边界 | -| 9.2 Windows 退化 | 三套独立 ABI 实现 | ✅ **单一 RPC 实现,跨平台一致** | -| 9.3 超时不可中断 | cgo 调用不可抢占 | ✅ **`Process.Kill()` 真正取消,零泄漏** | -| 9.4 output 假成功 | cgo 嵌套会崩 | ✅ **可同步等待真实结果** | - -9.2/9.3/9.4 都是**C ABI 前提的直接产物**——三套 ABI 实现、cgo 不可抢占、 -cgo 不可嵌套。这三条在进程边界下全部消失。 - -**迁移的正当性清单更新为 6 条**(完整版含依据与临时修复对照见 4.3): - -1. 热重载(原始动机) -2. 崩溃隔离 -3. 能力断层消除 -4. 内置插件解耦 -5. **修复 stage 副本模型的 lost update**(第八章) -6. **修复超时泄漏、output 假成功、Windows 能力退化**(本章) - -其中 **②③④ 没有临时替代方案**,是迁移的不可替代价值;①⑤⑥ 可先打补丁。 - -### 9.6 可立即修复项(不依赖迁移) - -> ⚠️ **本表 A-F 编号已废弃**,仅存档分组思路。实施请用 `plan.md` 的 11.1~11.6。 -> 对应关系:A→11.1、B→11.3、C→11.4、D→11.2、E→11.5、F→11.6。 - -按「影响 × 成本」排序: - -| # | 修复 | 影响 | 规模 | 备注 | -|---|---|---|---|---| -| **A** | `output_send` 改同步等待结果 | ❗ 消除"假成功",模型可重试 | M | 需绕开 cgo 嵌套:用 channel 把结果从 goroutine 传回并等待,而非在 cgo 栈内嵌套调用 | -| **B** | `stageContextWritable` 只回传变更字段 | ❗ 消除数据污染(8.6) | S | 需重编 17 插件 | -| **C** | Lua stage 快照加 `sc.RLock()` | 消除潜在 DATA RACE | S | 3 行改动 | -| **D** | 超时日志措辞改为"已放弃等待(插件仍在运行)" | 消除语义谎言 | S | 1 行;真正取消需子进程 | -| **E** | Windows stage 补齐字段 + 写回 | 跨平台一致 | M | 无 Windows 环境验证 | -| F | 阶段 0 的 0.1/0.2/0.3(ELF 检测等) | 消除 reload 误导 | S | 见 4.1 | - -**A 是最高优先级**:它直接影响用户可感知的行为(消息发不出去而模型以为成功), -且现网已有 2 次实际发生。 - -⚠️ A 的实现要点:不能简单改成同步调用(会触发 cgo 嵌套崩溃)。 -可行做法是保留 goroutine,但用带超时的 channel 等待其结果: - -```go -resCh := make(chan error, 1) -go func() { resCh <- pluginInvokeOutput(pid, chName, argsJSON) }() -select { -case err := <-resCh: - if err != nil { return nil, err } // 真实失败上报 - return map[string]interface{}{"status": "sent"}, nil -case <-time.After(10 * time.Second): - return map[string]interface{}{"status": "queued", "note": "发送超时未确认"}, nil -} -``` - -这样 `dev.Execute` 的调用栈不在 cgo 内(它由 `executeOutputSendTool` 从 Go 侧调起), -goroutine 内的 `pluginInvokeOutput` 才是 cgo 调用——不构成嵌套。 -**需实测验证不触发崩溃。** - -### 9.7 第三轮汇总 - -| # | 发现 | 严重度 | 现网状态 | -|---|---|---|---| -| 9.1 | Lua stage 快照无 `RLock`,DATA RACE | 中 | 未触发(无 Lua 插件) | -| 9.2 | Windows DLL 只下发 3 字段且无写回 | 高 | 未验证(无 Windows 部署) | -| 9.3 | cgo 超时不可中断,线性泄漏 goroutine+线程 | **高** | ❗ **已发生 26 次** | -| 9.4 | `output_send` 永远返回成功 | **高** | ❗ **已发生 2 次** | -| — | 实验 14:20 次卡死 → 泄漏 20 goroutine/18 线程 | — | 已复现 | -| — | 子进程 Kill 后零泄漏 | — | 已验证 | - ---- - -## 十、文档维护说明 - -### 10.1 三轮验证的递进关系 - -| 章 | 目的 | 方法 | 主要产出 | -|---|---|---|---| -| 一~六 | 建立评估框架 | 代码阅读 + 推理 | 目标架构、工作量、待定决策 | -| 七 | 验证**新架构可行性** | 11 项独立实验(`/tmp/feas/`) | 全部通过;裁定锁选型 | -| 八 | 检查**现有实现真实语义** | 精读 ABI 链路 + 复刻实验 | ❗ 推翻"约束 A"前提;发现现网污染 | -| 九 | **补盲**:此前未覆盖区域 | 遍历全部加载路径 + journal 统计 | ❗ 4 类新缺陷,2 项现网故障 | - -**方法论教训**:第八章推翻了第二章基于代码阅读得出的一个核心前提 -("stage 并发扇出改写同一对象"对外部插件不成立)。 -**读到 `RunStage` 的并发扇出就推断所有插件共享 `StageContext`, -漏掉了 ABI 边界会把引用降级为副本。** 后续评估应对每条跨边界路径单独追踪, -不能从进程内语义外推。 - -### 10.2 前文中已被后续章节修订的表述 - -以下位置保留了原始表述并加了指向更正的标注,阅读时请以后者为准: - -| 位置 | 原表述 | 更正 | -|---|---|---| -| 2.4 约束 A | stage 并发改写同一对象 | 仅对内置插件成立(第八章) | -| 2.2 代码规模表 | 只列 native + cabi | 实际四种加载路径(9.1/9.2) | -| 4.1 阶段 0 | 3 项(仅 reload 语义) | 7 项,新增 4 项优先级更高(9.6) | -| 4.2 总量 | 约 10 周 | 约 8-9 周(7.15) | -| 4.3 正当性 | 3 条 | **6 条**(4.3 与 9.5 已统一口径) | -| 4.4 风险表 | eventfd 待实测、开销 50-70MB | 已排除 / 实测 29MB | -| 五、待定决策 | 4 项全待定 | 2 项已裁定(7.14) | -| 3.6 eventfd | ⚠️ 待实测 | ✅ 已通过(7.2) | -| 3.7 锁选型 | 倾向锁仲裁回内核 | ✅ 已裁定(7.4/7.10) | - -### 10.3 实验代码(已固化入库) - -全部 18 项实验已固化到 [`experiments/plugin-arch/`](experiments/plugin-arch/), -带一键复跑脚本,**不再依赖 `/tmp`**: - -```bash -cd docs/zh/experiments/plugin-arch -./run.sh # 全部 18 项,约 3-5 分钟 -./run.sh 12 13 # 只跑指定实验 -``` - -| 目录 | 主题 | 对应章节 | -|---|---|---| -| `01-dlclose-nodelete/` | `dlclose` 对 `DF_1_NODELETE` 是 no-op(3 项) | 1.1 / 1.2 | -| `02-feasibility/` | 新架构可行性(11 项) | 第七章 | -| `03-lost-update/` | 副本模型 lost update(2 项) | 8.4 / 8.6 | -| `04-cgo-uninterruptible/` | cgo 调用不可中断(2 项) | 9.3 | - -实现要点:源码均带 `//go:build ignore`(不进主构建), -`run.sh` 在 `mktemp -d` 内构建(不污染主仓 `go.mod`), -需 `x/sys` 的实验按需拉取(本机走 clash `127.0.0.1:7890`)。 - -**复跑验证**:2026-08-31 全量跑通,18/18 通过。 - -⚠️ **部分数字随调度波动**,详见 `experiments/plugin-arch/README.md` 的 -「复跑时的注意事项」——其中区分了「允许波动」与「不应变的断言」。 -最重要的一条:**8.6 的 1.6% 污染率在复跑中观测到 1.6%~4.3% 区间**, -应理解为「量级在百分之几」而非精确常数。 - -### 10.4 与 plan.md 的分工 - -- **本文档**:论证、实验数据、架构设计、决策依据 —— 回答"为什么"和"怎么设计" -- **`plan.md` 第 11 节**:可勾选的修复项、文件级改动位置、实施顺序 —— 回答"做什么" - -修复项的进度只在 `plan.md` 维护,本文档不重复勾选状态。 diff --git a/internal/plugin/dynamic_proc.go b/internal/plugin/dynamic_proc.go index cd6782d..deafe4e 100644 --- a/internal/plugin/dynamic_proc.go +++ b/internal/plugin/dynamic_proc.go @@ -40,8 +40,8 @@ func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, e // 子进程插件加载(plugin.bin)——外部插件多进程化的加载入口。 // -// 设计依据:docs/zh/架构迁移评估.md §3(stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面) -// 实施记录见 docs/zh/架构迁移评估.md(子进程化论证与实验数据) +// 三个通信面:stdio JSON-RPC(控制)+ 共享内存段(数据)+ eventfd 通知环。 +// 设计取舍见 assets/docs/zh/ARCHITECTURE.md「子进程插件的三个通信面」。 // validateProcBinary 校验 plugin.bin 是否存在且可执行。 // 返回 ("", nil) 表示该目录不是 proc 插件。 diff --git a/internal/plugin/entry_dispatch_test.go b/internal/plugin/entry_dispatch_test.go index 44ac752..b5a5398 100644 --- a/internal/plugin/entry_dispatch_test.go +++ b/internal/plugin/entry_dispatch_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -// entry 分派(见 docs/zh/架构迁移评估.md 的入口双通道章节)。 +// entry 分派:按 manifest 的 entry 把插件分派到 proc / lua / skill 三条通道。 // // C ABI 通道(.so/.dll/.dylib)已整体退场:外部插件统一走子进程 + stdio RPC。 // 这些测试守住的是「旧产物给明确错误」而非「静默跳过」——后者会让 diff --git a/internal/plugin/proc/bench_test.go b/internal/plugin/proc/bench_test.go index 7540389..ee77bba 100644 --- a/internal/plugin/proc/bench_test.go +++ b/internal/plugin/proc/bench_test.go @@ -12,7 +12,7 @@ import ( // 子进程架构的性能基准(Part 6.6 验收项)。 // -// 对照基线来自 docs/zh/experiments/plugin-arch: +// 对照基线(迁移期可行性实验实测,那些实验脚本已随迁移完成删除): // // 实验 3 锁仲裁 RPC 往返 19.40 µs/次 // 实验 4 post-and-forget 5.07s → 2.29ms(5000 token + 20µs 慢消费者) diff --git a/internal/plugin/proc/process.go b/internal/plugin/proc/process.go index b812af0..3fee74b 100644 --- a/internal/plugin/proc/process.go +++ b/internal/plugin/proc/process.go @@ -19,7 +19,7 @@ import ( // Process 管理一个外部插件子进程:spawn / 双向 JSON-RPC / 优雅停止 / 崩溃检测。 // -// 设计依据:docs/zh/架构迁移评估.md §4.1 阶段 2、§2.3(保留现有生命周期机制) +// 生命周期:沿用内核既有的注册面摘除 + 退避重启机制(见 stats/registry)。 // // 与 C ABI 路径的关键差异: // - **崩溃隔离**:插件 panic 只让子进程退出,homed 存活(今日 panic 跨 C 栈可带崩内核) diff --git a/internal/plugin/proc/shm.go b/internal/plugin/proc/shm.go index 3339831..3718b82 100644 --- a/internal/plugin/proc/shm.go +++ b/internal/plugin/proc/shm.go @@ -1,6 +1,7 @@ // Package proc 实现外部插件的子进程加载通道(plugin.bin)。 // -// 设计依据:docs/zh/架构迁移评估.md 第三章 +// 数据面设计:定长头 + arena,变长数据用相对基址的 {off,len} 描述符 +//(相对偏移是关键:各进程 mmap 到不同虚拟地址也能正确解引用)。 // // homed ──spawn──> plugin(纯 Go 二进制,无 cgo) // ├── stdio JSON-RPC 控制面:51 个 method id 平移为 method 名(§3.2) diff --git a/plan.md b/plan.md index fe41c96..18fff9d 100644 --- a/plan.md +++ b/plan.md @@ -607,12 +607,12 @@ CLI 本机自执行命令已有 cmd 插件,不做反向操控 CLI。 ## 11. 插件架构缺陷修复 + 子进程化迁移评估 -> 完整评估文档:[`docs/zh/架构迁移评估.md`](docs/zh/架构迁移评估.md) -> —— **先读其第零章「给接手者的阅读指引」**,该文档是增量写成的,前六章部分结论已被后续推翻。 -> 可复跑实验:[`docs/zh/experiments/plugin-arch/`](docs/zh/experiments/plugin-arch/)(18 项,`./run.sh`) +> 迁移**已于 v1.0.0 完成并上生产**。评估稿、可行性实验(18 项)与迁移基线矩阵 +> 均为迁移期临时文档,已随迁移完成一并删除(见 git 历史)。 +> 现状架构见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md) 的「插件系统」; +> 当前运行态的事实以源码为准(`internal/plugin/`)。 > -> **本节 11.1~11.6 是修复项的唯一权威编号。** 评估文档中出现的 `0.x` / `A-F` -> 仅为历史分组,勿用于实施。本节只列可执行项与决策状态;论证与数据见评估文档。 +> **本节 11.1~11.6 保留为历史工单**,各条勾选状态即当时的完成情况。 ### 11.0-pre 三个易被误解的前提(动手前必读) @@ -933,8 +933,8 @@ context 累积导致的内存增长。 > 三项合入门禁**已全部通过**:`make test` 零失败、`go vet ./...` 无告警、 > `git diff main -- third_party/homeagent-sdk/sdk/` 为空(接口冻结不变量)。 > -> 迁移的执行记录(Part 0~6 过程稿)已随迁移完成而删除;论证与实验数据仍见 -> `docs/zh/架构迁移评估.md`。 +> 迁移的执行记录、评估稿与可行性实验均已随迁移完成而删除(见 git 历史); +> 现状以 `assets/docs/zh/ARCHITECTURE.md` 与源码为准。 ### 12.1 ✅ 已完成:合并到 main + 发布分支(2026-09-03 ~ 09-06)