mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
docs(plugin-arch): 归档插件架构迁移评估 + plan 第11节整改计划
- docs/zh/架构迁移评估.md: C ABI→子进程+共享内存完整迁移论证(1621行) - docs/zh/experiments/: 18项可复跑可行性实验(架构评估的所有数字来源) - plan.md §11: 11.1~11.9 插件架构缺陷修复清单(唯一权威编号) - main 保持干净,本批次为 update 特性分支的整改起点
This commit is contained in:
@ -0,0 +1,72 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
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 "<no sym>" }
|
||||
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(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
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✅ 卸载成功")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
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)
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V1"; }
|
||||
@ -0,0 +1,2 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V2"; }
|
||||
@ -0,0 +1,9 @@
|
||||
#include <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
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); }
|
||||
49
docs/zh/experiments/plugin-arch/02-feasibility/exp10.go
Normal file
49
docs/zh/experiments/plugin-arch/02-feasibility/exp10.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
42
docs/zh/experiments/plugin-arch/02-feasibility/exp11.go
Normal file
42
docs/zh/experiments/plugin-arch/02-feasibility/exp11.go
Normal file
@ -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)
|
||||
}
|
||||
12
docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go
Normal file
12
docs/zh/experiments/plugin-arch/02-feasibility/exp11_plug.go
Normal file
@ -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() }
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
41
docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go
Normal file
41
docs/zh/experiments/plugin-arch/02-feasibility/exp2_child.go
Normal file
@ -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")
|
||||
}
|
||||
@ -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]))
|
||||
}
|
||||
}
|
||||
31
docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go
Normal file
31
docs/zh/experiments/plugin-arch/02-feasibility/exp3_child.go
Normal file
@ -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))
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
71
docs/zh/experiments/plugin-arch/02-feasibility/exp4.go
Normal file
71
docs/zh/experiments/plugin-arch/02-feasibility/exp4.go
Normal file
@ -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 使流式发布与消费者速度解耦")
|
||||
}
|
||||
}
|
||||
55
docs/zh/experiments/plugin-arch/02-feasibility/exp5.go
Normal file
55
docs/zh/experiments/plugin-arch/02-feasibility/exp5.go
Normal file
@ -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() }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
68
docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go
Normal file
68
docs/zh/experiments/plugin-arch/02-feasibility/exp5b.go
Normal file
@ -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() }
|
||||
}
|
||||
49
docs/zh/experiments/plugin-arch/02-feasibility/exp6.go
Normal file
49
docs/zh/experiments/plugin-arch/02-feasibility/exp6.go
Normal file
@ -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")
|
||||
}
|
||||
23
docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go
Normal file
23
docs/zh/experiments/plugin-arch/02-feasibility/exp6_crash.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
63
docs/zh/experiments/plugin-arch/02-feasibility/exp7.go
Normal file
63
docs/zh/experiments/plugin-arch/02-feasibility/exp7.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
84
docs/zh/experiments/plugin-arch/02-feasibility/exp8.go
Normal file
84
docs/zh/experiments/plugin-arch/02-feasibility/exp8.go
Normal file
@ -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")
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
60
docs/zh/experiments/plugin-arch/02-feasibility/exp9.go
Normal file
60
docs/zh/experiments/plugin-arch/02-feasibility/exp9.go
Normal file
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
90
docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go
Normal file
90
docs/zh/experiments/plugin-arch/03-lost-update/exp12/main.go
Normal file
@ -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 非原子:快照与写回之间的窗口导致覆盖")
|
||||
}
|
||||
101
docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go
Normal file
101
docs/zh/experiments/plugin-arch/03-lost-update/exp13/main.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
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 回收全部资源")
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
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 线程),无法被抢占或回收")
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
#include <unistd.h>
|
||||
void hang_forever(void) { while(1) sleep(1); }
|
||||
111
docs/zh/experiments/plugin-arch/README.md
Normal file
111
docs/zh/experiments/plugin-arch/README.md
Normal file
@ -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 缺陷**未经实测**,仅代码阅读
|
||||
127
docs/zh/experiments/plugin-arch/run.sh
Executable file
127
docs/zh/experiments/plugin-arch/run.sh
Executable file
@ -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" <<EOF
|
||||
module exp
|
||||
go 1.21
|
||||
require golang.org/x/sys v0.20.0
|
||||
EOF
|
||||
(cd "$1" && GOFLAGS=-mod=mod go get golang.org/x/sys@v0.20.0 >/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 <<EOF
|
||||
package main
|
||||
import "C"
|
||||
//export lib_version
|
||||
func lib_version() *C.char { return C.CString("$V-CODE") }
|
||||
func main() {}
|
||||
EOF
|
||||
printf 'module gl%s\ngo 1.21\n' $V > $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 ]
|
||||
1621
docs/zh/架构迁移评估.md
Normal file
1621
docs/zh/架构迁移评估.md
Normal file
File diff suppressed because it is too large
Load Diff
315
plan.md
315
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 驻留来源
|
||||
|
||||
Reference in New Issue
Block a user