From 304cad06484f1b6699fe9a7e635e7bafe6ee7c44 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 31 Aug 2026 11:45:52 +0800 Subject: [PATCH] =?UTF-8?q?docs(plugin-arch):=20=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=9E=B6=E6=9E=84=E8=BF=81=E7=A7=BB=E8=AF=84?= =?UTF-8?q?=E4=BC=B0=20+=20plan=20=E7=AC=AC11=E8=8A=82=E6=95=B4=E6=94=B9?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/zh/架构迁移评估.md: C ABI→子进程+共享内存完整迁移论证(1621行) - docs/zh/experiments/: 18项可复跑可行性实验(架构评估的所有数字来源) - plan.md §11: 11.1~11.9 插件架构缺陷修复清单(唯一权威编号) - main 保持干净,本批次为 update 特性分支的整改起点 --- .../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 + docs/zh/experiments/plugin-arch/README.md | 111 ++ docs/zh/experiments/plugin-arch/run.sh | 127 ++ docs/zh/架构迁移评估.md | 1621 +++++++++++++++++ plan.md | 315 ++++ 34 files changed, 3552 insertions(+) create mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01a/main.go create mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01b/main.go create mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01c/main.go create mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v1.c create mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v2.c create mode 100644 docs/zh/experiments/plugin-arch/01-dlclose-nodelete/shim.c create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp10.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp11.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp1_eventfd.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp2_parent.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp3_parent.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp4.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp5.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp5_plugin.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp6.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp7.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp8.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp8_worker.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp9.go create mode 100644 docs/zh/experiments/plugin-arch/02-feasibility/exp9_worker.go create mode 100644 docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go create mode 100644 docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go create mode 100644 docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14a/main.go create mode 100644 docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14b/main.go create mode 100644 docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/hang.c create mode 100644 docs/zh/experiments/plugin-arch/README.md create mode 100755 docs/zh/experiments/plugin-arch/run.sh create mode 100644 docs/zh/架构迁移评估.md 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 new file mode 100644 index 0000000..fbe6a40 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01a/main.go @@ -0,0 +1,72 @@ +//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 new file mode 100644 index 0000000..8f32abc --- /dev/null +++ b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01b/main.go @@ -0,0 +1,49 @@ +//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 new file mode 100644 index 0000000..c1c0ed6 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/exp01c/main.go @@ -0,0 +1,58 @@ +//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 new file mode 100644 index 0000000..f937edb --- /dev/null +++ b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v1.c @@ -0,0 +1,2 @@ +#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 new file mode 100644 index 0000000..908d970 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/probe_v2.c @@ -0,0 +1,2 @@ +#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 new file mode 100644 index 0000000..9425842 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/01-dlclose-nodelete/shim.c @@ -0,0 +1,9 @@ +#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 new file mode 100644 index 0000000..2a519c7 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp10.go @@ -0,0 +1,49 @@ +//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 new file mode 100644 index 0000000..439995f --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp11.go @@ -0,0 +1,42 @@ +//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 new file mode 100644 index 0000000..586342d --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go @@ -0,0 +1,12 @@ +//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 new file mode 100644 index 0000000..89d1e33 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp1_eventfd.go @@ -0,0 +1,58 @@ +//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 new file mode 100644 index 0000000..f81e782 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go @@ -0,0 +1,41 @@ +//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 new file mode 100644 index 0000000..ad3dc34 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp2_parent.go @@ -0,0 +1,60 @@ +//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 new file mode 100644 index 0000000..6fad0d8 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go @@ -0,0 +1,31 @@ +//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 new file mode 100644 index 0000000..5e7d88d --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp3_parent.go @@ -0,0 +1,37 @@ +//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 new file mode 100644 index 0000000..acc7143 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp4.go @@ -0,0 +1,71 @@ +//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 new file mode 100644 index 0000000..332b7d2 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp5.go @@ -0,0 +1,55 @@ +//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 new file mode 100644 index 0000000..28e294f --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp5_plugin.go @@ -0,0 +1,23 @@ +//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 new file mode 100644 index 0000000..e974e93 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go @@ -0,0 +1,68 @@ +//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 new file mode 100644 index 0000000..e8540d6 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp6.go @@ -0,0 +1,49 @@ +//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 new file mode 100644 index 0000000..dbf7ed6 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go @@ -0,0 +1,23 @@ +//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 new file mode 100644 index 0000000..c25f84f --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp7.go @@ -0,0 +1,63 @@ +//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 new file mode 100644 index 0000000..a74d5eb --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp8.go @@ -0,0 +1,84 @@ +//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 new file mode 100644 index 0000000..336a75a --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp8_worker.go @@ -0,0 +1,50 @@ +//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 new file mode 100644 index 0000000..99a852c --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp9.go @@ -0,0 +1,60 @@ +//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 new file mode 100644 index 0000000..5794a1e --- /dev/null +++ b/docs/zh/experiments/plugin-arch/02-feasibility/exp9_worker.go @@ -0,0 +1,12 @@ +//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 new file mode 100644 index 0000000..3e9cc45 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go @@ -0,0 +1,90 @@ +//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 new file mode 100644 index 0000000..aecc13c --- /dev/null +++ b/docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go @@ -0,0 +1,101 @@ +//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 new file mode 100644 index 0000000..df399df --- /dev/null +++ b/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14a/main.go @@ -0,0 +1,62 @@ +//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 new file mode 100644 index 0000000..0e0f79e --- /dev/null +++ b/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/exp14b/main.go @@ -0,0 +1,43 @@ +//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 new file mode 100644 index 0000000..14375d2 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/04-cgo-uninterruptible/hang.c @@ -0,0 +1,2 @@ +#include +void hang_forever(void) { while(1) sleep(1); } diff --git a/docs/zh/experiments/plugin-arch/README.md b/docs/zh/experiments/plugin-arch/README.md new file mode 100644 index 0000000..4dfae51 --- /dev/null +++ b/docs/zh/experiments/plugin-arch/README.md @@ -0,0 +1,111 @@ +# 插件架构评估实验 + +[`../../架构迁移评估.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 new file mode 100755 index 0000000..f9f687d --- /dev/null +++ b/docs/zh/experiments/plugin-arch/run.sh @@ -0,0 +1,127 @@ +#!/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/架构迁移评估.md b/docs/zh/架构迁移评估.md new file mode 100644 index 0000000..8d1bbef --- /dev/null +++ b/docs/zh/架构迁移评估.md @@ -0,0 +1,1621 @@ +# 插件架构迁移评估:从 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/plan.md b/plan.md index cb189ff..e903340 100644 --- a/plan.md +++ b/plan.md @@ -592,3 +592,318 @@ CLI 本机自执行命令已有 cmd 插件,不做反向操控 CLI。 - Step3:生命周期(window-all-closed 退出进托盘依偏好、before-quit 清理托盘/设备桥)— 显示正常 ✅ **最终版已安装**:/opt/HomeAgent(md5 047f2f1bbe,备份 /tmp/ha-app-step3-final.asar),含:设备桥 / 设备页+授权开关 / 惰性托盘 / prefs 持久化 / 退出进托盘 / 字体/圆角(renderer)。 + +--- + +## 11. 插件架构缺陷修复 + 子进程化迁移评估 + +> 完整评估文档:[`docs/zh/架构迁移评估.md`](docs/zh/架构迁移评估.md) +> —— **先读其第零章「给接手者的阅读指引」**,该文档是增量写成的,前六章部分结论已被后续推翻。 +> 可复跑实验:[`docs/zh/experiments/plugin-arch/`](docs/zh/experiments/plugin-arch/)(18 项,`./run.sh`) +> +> **本节 11.1~11.6 是修复项的唯一权威编号。** 评估文档中出现的 `0.x` / `A-F` +> 仅为历史分组,勿用于实施。本节只列可执行项与决策状态;论证与数据见评估文档。 + +### 11.0-pre 三个易被误解的前提(动手前必读) + +1. **stage 的并发扇出是原始设计,不是缺陷。** + `stages.go:124` 的 `go func` + `wg.Wait()` 是刻意的,`StageContext` 的 `RWMutex` + 与公开 `Lock/RLock` 就是为它准备的。**问题是 C ABI 把外部插件降级成副本模型**, + 使那把锁在 ABI 边界外变成空转(内置 0% 丢失 vs 副本 35.8~36.8%)。 + → 不要试图"取消并发"来修 11.3。 + +2. **内置插件的高权限是刻意设计,不是"自己人所以安全"。** + 但当前实现混淆了「应有的权限梯度」与「C ABI 表达能力天花板」: + 外部插件拿不到 `OutputChan`/`Subscribe` 是技术限制(`case 23/24` 是空实现, + 属"给不了"),而非权限决定。迁移目标是让梯度**显式化并强制**,不是消除梯度。 + +3. **副本模型是"为方便插件加载的无奈之举"。** + C ABI 用于绕开 Go 原生 `plugin` 包的同版本限制,副本模型是其必然代价。 + 问题在于该代价未被记录、后果未被发现——不是当初的选择错了。 + +### 11.0 起因 + +更换 `plugin.so` 后 `plgreload` 报成功但运行旧代码。根因是 Go c-shared 的 +ELF `DF_1_NODELETE` 标记使 `dlclose` 成为 no-op,**换 `.so` 必须重启 homed**。 +排查该问题时连带发现 6 类此前未知的缺陷,其中 **2 项正在生产环境造成故障**。 + +### 11.1 紧急:output_send 永远返回成功 ⚠️ 现网已发生 + +**现象**:模型调用 `output_send__qq` 收到「已发送」,但消息实际未送达,模型不知道也不重试。 + +**根因**(`internal/plugin/cabi/loader.go:458-470`)——注释自己写明了原因: + +```go +// 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, argsJSON); err != nil { + log.Printf("[dispatch] async output %s/%s failed: %v", ...) // ← 仅日志 + } +}() +return map[string]interface{}{"status": "queued"}, nil // ← 立即返回"成功" +``` + +`output.go:65-70` 拿到 `{status:queued}` + `err=nil`,返回给模型「已通过 [qq] 通道发送」。 + +**现网证据**(近 7 天):成功 44 次,失败 2 次。 + +``` +Aug 30 15:10:51 [dispatch] async output qq/qq failed: + invoke_output qq: meta 中需要 group_id 或 user_id 字段 +``` + +**与此前排查的关系**:之前诊断「qq 渠道回复丢失」时修复了系统提示词 +(`a3a5cd4`,强调 qq 是异步通道、必须用 `output_send`), +但**未发现 `output_send` 本身永远报成功**——模型即使正确调用也无法感知失败。 + +**修复方案**(保留 goroutine + 带超时 channel 等待,避免 cgo 嵌套): + +```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` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内), +goroutine 里的 `pluginInvokeOutput` 才是 cgo 调用,**不构成嵌套**。 + +- [ ] 实现修复 +- [ ] **实测验证不触发 cgo 嵌套崩溃**(此判断为推理,必须实测) +- [ ] 构造 meta 缺 `user_id` 的失败场景,确认模型收到错误而非"已发送" + +### 11.2 紧急:cgo 工具超时不可中断,线性泄漏 ⚠️ 现网已发生 26 次 + +**现象**:`toolcall.go:41` 日志称「已取消」,实际什么都没取消。 + +**根因**:`select` 超时只让调用方返回,goroutine 仍卡在 `C.call_invoke_tool` 里。 +**cgo 调用不可被 Go runtime 抢占或取消**,该 OS 线程永久占用。 + +**实验 14 实测**(纯 C 死循环 `.so`,20 次卡死调用): + +``` + 5 次后: goroutines= 6 threads= 9 (+3) +10 次后: goroutines=11 threads=14 (+8) +20 次后: goroutines=21 threads=24 (+18) +线性泄漏,永不回收 +``` + +对照:子进程模型 `Process.Kill()` 后 OS 回收全部资源,**零泄漏**。 + +**现网统计**(近 14 天 26 次超时): + +``` + 9 browser_screenshot 2 browser_type 1 cmd_run + 5 browser_render 2 browser_start 1 browser_html + 2 output_send__webui 2 browser_click 1 browser_fetch +``` + +`browser` 插件占 22/26。历史进程(`homed[1063615]`、`homed[2609279]`)必然已累积泄漏。 + +- [ ] **短期**:日志措辞改为「已放弃等待(插件仍在后台运行,其占用的线程无法回收)」 + —— 消除语义谎言,1 行改动 +- [ ] **短期**:排查 `browser` 插件为何频繁 60s 超时(22/26 集中于它) +- [ ] 真正的取消能力需子进程模型(见 11.7) + +### 11.3 stage 副本模型的 lost update ⚠️ 现网数据污染(量级百分之几) + +**核心事实更正**:外部 `.so` 插件**从未共享过 `StageContext`**,一直是「快照-副本-写回」: + +``` +内核 sc.RLock() → 快照 10 字段为 JSON → 跨 ABI + → go_invoke_stage: sc := &sdk.StageContext{} ← 插件进程内全新对象 + → handler 改副本(其 ctx.Lock() 是空操作,无跨插件互斥) + → stageContextWritable → Marshal 回传 + → applyStageResult: sc.Lock() 逐字段写回 +``` + +这是**为方便插件加载的无奈之举**(C ABI 无法传 Go 对象引用),但带来三个后果: + +1. **`ctx.Lock()` 是空操作** —— 插件按文档正确加锁,锁语义在 ABI 边界静默失效 +2. **字段被裁剪** —— 16 个字段只下发 10 个,`ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` 外部插件永远看不到 +3. **lost update** —— read-modify-write 非原子,实验 12 实测丢失率 **36.8%**(内置模型 0%) + +**现网触发点**:`AfterToolcall` 上有两个外部插件 + +| Stage | 注册者 | 风险 | +|---|---|---| +| `AfterToolcall` | **sanitizer**(Global,改写 ToolResults) + **weather**(own_tools,只读) | ⚠️ 真实冲突 | +| `PreAction` | memo(外部) + webui(内置) | ⚡ | +| `BeforeToolcall` | qq(外部) + webui(内置) + cmd(内置) | ⚡ | + +根因在 `templates.go:762` —— **无条件回传未修改字段**: + +```go +if len(sc.ToolResults) > 0 { + m["tool_results"] = sc.ToolResults // weather 没改也回传它收到的旧快照 +} +``` + +**实验 13 复刻现网场景**(模型调用 `weather_query`,3000 轮): + +``` +47 轮 sanitizer 的清洗结果被 weather 的旧快照覆盖 (1.6%) +→ 脏数据(ANSI 转义)进入 LLM 上下文 +``` + +⚠️ **该比率不是常数**:三次复跑得 1.6% / 2.1% / 4.3%,取决于两插件 handler 的 +实际耗时比。**应表述为「量级百分之几」**,不要把 1.6% 当精确值写进代码注释或对外说明。 + +⚠️ **修复方向的红线**:不要通过"把 `RunStage` 改成串行"来消除冲突。 +并发扇出是原始设计(见 11.0-pre 第 1 条),串行化会改变所有 stage 插件的时序语义, +且掩盖真正的根因(副本模型 + 无条件回传)。正确做法是让回传只带真正变更的字段。 + +现网条件已确认:sanitizer v0.1.0 / weather v1.0.0 均 8/15 部署、`disabled_plugins` 为空、 +日志有 `[sanitizer] stage OnInput/AfterToolcall/PostAction registered`。 + +**修复**:`stageContextWritable` 只回传**真正变更**的字段 + +```go +before := stageContextWritable(sc) +if err := h(sc); err != nil { ... } +diff := changedFieldsOnly(before, stageContextWritable(sc)) +``` + +- [ ] 实现 diff 回传 +- [ ] ⚠️ **需重新编译并安装全部 17 个外部插件**(bridge 模板变更) + —— 必须走 `plugindev` 正规工具链 + `plugin_install(url, overwrite=true)` 内核接口 +- [ ] 验证:weather_query 调用后 tool_results 保持已清洗状态 + +### 11.4 Lua stage 快照缺读锁(DATA RACE) + +`lua_plugin.go:726` 直接读 `sc.RawMessage` 等字段,**未持 `sc.RLock()`**: + +| 路径 | 快照时是否持锁 | +|---|---| +| cabi(`loader.go:412`) | ✅ `sc.RLock()` | +| Lua(`lua_plugin.go:726`) | ❌ 无锁 | + +`RunStage` 是并发扇出,这与其他 handler 的 `sc.Lock()` 构成数据竞争。 + +**现网未触发**(无 Lua 插件部署,`find` 无 `main.lua`),但缺陷已存在。 + +- [ ] 加 `sc.RLock()`/`sc.RUnlock()` 包裹快照构造(约 3 行) + +### 11.5 Windows DLL 路径能力严重退化 + +`dynamic_dll_windows.go:227-245`: + +```go +ctxJSON, _ := json.Marshal(map[string]interface{}{ + "raw_message": sc.RawMessage, "user_id": sc.UserID, "phase": string(sc.Phase), +}) // ← 只有 3 个字段 +syscall.SyscallN(p.invokeStage, p.handle, ...) +return nil // ← 无 resultOut,无 applyStageResult +``` + +| 路径 | 下发字段 | 写回 | +|---|---|---| +| Linux cabi | 10 | ✅ | +| Lua | 10 | ✅ | +| **Windows DLL** | **3** | ❌ **完全没有** | + +**后果**:`sanitizer` 类改写型插件在 Windows 上**静默失效**——handler 正常执行、 +日志正常打印,修改全部丢弃;且看不到 `llm_text`/`tool_calls`/`tool_results`。 + +- [ ] 补齐字段下发 + 写回(无 Windows 环境,需借测试机验证) + +### 11.6 reload 语义谎言(原始起因) + +`plgreload` 对 `.so` 插件报成功但运行旧代码。已实验确证 `dlclose` 对 +`DF_1_NODELETE` 是 no-op,且**套任何层数的 C 中间件都绕不过去** +(NODELETE 属于被卸载对象自身的 ELF 属性)。 + +已评估并**否决**版本化路径方案:技术上可行,但每次重载永久泄漏 +**5.8 个线程 + 1.5MB**(30 次实测 +168 线程 / +46MB),对 24/7 常驻进程不可接受。 + +- [ ] ELF 检测 `DF_1_NODELETE` → 标记插件"不可热重载"(`dynamic_loader_unix.go`) +- [ ] `ReloadOne` 对此类插件返回"需重启 homed",停止假装成功(`registry.go`) +- [ ] `plugin_install` 返回 `restart_required` 替代误导性的 `reload_required`(`pluginmgr/plugin.go`) + +### 11.7 子进程 + 共享内存架构迁移(待决策) + +**目标架构**: + +``` +今天: homed ──dlopen──> plugin.so(cgo bridge 385 行 + 51 个整数 method id) + ↑ C 层唯一目的:绕开 Go plugin 包同版本限制 + +之后: homed ──spawn──> plugin(纯 Go 二进制,零 cgo) + ├── stdio JSON-RPC 控制面:51 个 case 平移为 method 名 + ├── shm + 偏移 数据面:StageContext 并发改写、二进制零拷贝 + └── eventfd 通知面:事件环 post-and-forget +``` + +**关键洞察**:C 中间层存在的唯一理由是绕开 Go 原生 `plugin` 包的版本枷锁。 +子进程模型下**进程边界本身就是 ABI 边界**,C 层解决的问题消失,C 层自己也就该消失。 +可删除 `cabi/` 1096 行 + 每插件 385 行 bridge 模板。 + +**11 项可行性实验全部通过**(详见评估文档第七章): + +| 验证项 | 结果 | +|---|---| +| eventfd 走 netpoller | ✅ 200 等待者仅 +1 线程 | +| 跨进程偏移解引用 | ✅ 父子 mmap 不同基址仍正确 | +| 锁仲裁 RPC 成本 | ✅ 19.4 µs/次 | +| post-and-forget 解耦流式 | ✅ 2218x 加速 | +| 17 子进程常驻开销 | ✅ 29MB RSS(原估 50-70MB) | +| 崩溃隔离 + 退出码信号 | ✅ 退出码 2,EOF 2.5ms 感知 | +| 子进程热重载 | ✅ 同路径替换即生效 | +| **跨进程并发改写 StageContext** | ✅ 5 插件×300 轮零丢失 | +| 持锁进程崩溃自愈 | ✅ 无需 robust mutex,**零 cgo** | +| 二进制零拷贝 | ✅ 18-22x,体积省 100% | +| 工具调用 RPC 延迟 | ✅ p50 19.5 µs | + +**工作量约 8-9 周**(6 阶段,详见评估文档第四章)。 +双通道共存(按 manifest `entry` 分派 `.so`/`.bin`)使迁移可逐插件推进、随时回退。 + +**迁移正当性 6 条**:① 热重载 ② 崩溃隔离 ③ 能力断层消除 +④ 内置插件解耦 ⑤ 修复 stage lost update ⑥ 修复超时泄漏/output 假成功/Windows 退化 + +其中 ②③④⑥ 全是 C ABI 前提的直接产物(三套 ABI 实现、cgo 不可抢占、cgo 不可嵌套), +在进程边界下自动消失。 + +**待决策**: + +- [ ] **是否全量迁移?** 若只为热重载,11.6(1 人日)即够;8-9 周投入的理由必须是 ②-⑥ +- [x] 跨进程锁选型 → **已裁定**:锁仲裁回内核,零 cgo(实验 3+9) +- [x] `Extra` 处置 → **维持**:4 键提升为具名字段,`Extra` 留 RPC 副本 +- [ ] 权限梯度显式形式(manifest 声明 caps?内核白名单?) + —— 注:内置插件的高权限是**刻意设计**,迁移目标是让梯度从"C ABI 表达能力的 + 意外产物"变成"显式声明并强制的策略",而非消除梯度 + +### 11.8 实施顺序建议 + +按「影响 × 成本」排序,前 4 项不依赖迁移决策: + +| 序 | 项 | 规模 | 现网影响 | +|---|---|---|---| +| 1 | **11.1** output_send 同步等结果 | M | ❗ 用户收不到消息且模型以为成功 | +| 2 | **11.3** stageContextWritable diff 回传 | S | ❗ 脏数据进 LLM(量级百分之几) | +| 3 | **11.6** reload 语义修正(3 项) | S | 误导模型白跑重载 | +| 4 | **11.2** 超时日志措辞 + browser 排查 | S | 已泄漏 26 次 | +| 5 | 11.4 Lua 读锁 | S | 潜在 | +| 6 | 11.5 Windows 补齐 | M | 无部署 | +| 7 | 11.7 迁移(待决策) | 8-9 周 | — | + +### 11.9 附带发现(独立问题,非本节范围) + +测量对照数据时发现 homed 内存异常: + +``` +homed RSS = 2.34 GB RssAnon = 2.35 GB(真实驻留) + 2420 MB × 1 ← 主 homed 的 Go heap + 512 MB × 15 ← 15 个插件各自的 heap arena(虚拟预留,不占物理内存) +``` + +512MB×15 说明当前架构下**插件间内存无法协同回收**(各自独立 Go runtime)。 +但 **2.36 GB 真实驻留在主 homed heap 上,与插件无关**,疑似 chat history / +context 累积导致的内存增长。 + +- [ ] 单独排查 homed 主 heap 的 2.36GB 驻留来源