mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
docs: 删除迁移期临时文档,现行内容搬进正式文档
用户指出迁移评估那批是**过程性临时文档**,迁移已完成就该退场。
## 删除(38 个文件)
- docs/zh/架构迁移评估.md(1621 行)—— 评估稿。开头的「❗现网正在发生的问题」
(output_send 永远成功 / cgo 超时泄漏 26 次 / stage 污染)**全部已修复**,
留着是误导性告警。其 §三「目标架构」已被 ARCHITECTURE.md 完整覆盖
(且后者更细,含子进程生命周期管理)。
- docs/zh/plugin-interface-matrix.md(428 行)—— 迁移基线矩阵。
- docs/zh/experiments/(36 文件)—— 18 项可行性实验,验证的是"该不该迁移",
迁移早已完成;实测无任何构建/测试依赖它。
## 现行内容先搬走(不能随临时文档一起丢)
- plugin-interface-matrix §九「接口扩展规则」→ 搬进 docs/git-branching.md 新增 §八
(只增不减/签名不改、新增必须"插件调用内核实现"方向、hmapdev 模板必须同步接线
否则全体插件编译失败、"接口纯追加"≠"无需重编"、合回 main 的同步清单)。
- git-branching §六 原写「接口冻结是合回门禁」—— 冻结是**迁移期**约束,v1.1.x 起
已到期,改为标注失效并指向 §八。
## 引用清理
8 处引用全部改指现行文档:plan.md ×3、两篇设计文档各 ×1、
4 处源码注释(proc/shm.go、proc/process.go、dynamic_proc.go、entry_dispatch_test.go、
proc/bench_test.go)。仅 third_party(SDK 独立仓)保留 1 处,不动。
## 验证
- `go build ./...` 通过;`go test ./internal/plugin/...` 两个包全绿
- 本项目文档**断链 0**(另 2 处断链在 oh_modules 第三方依赖内)
This commit is contained in:
@ -322,10 +322,15 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
|
||||
## 六、本规范与「接口冻结」约束的关系
|
||||
|
||||
- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
||||
- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`)。
|
||||
- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七),
|
||||
并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag。
|
||||
> **接口冻结已到期(v1.1.x 起)**。冻结是**迁移期**的约束——它要保的是
|
||||
> 「换运行模型不动业务代码」,靠 `git diff third_party/homeagent-sdk/sdk/` 为空来守。
|
||||
> 迁移完成(v1.0.0 上生产)后该约束按时失效,取而代之的是 §八的三条演进规则。
|
||||
> 本节保留历史条款,但**不再作为合回门禁**。
|
||||
|
||||
- ~~feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)~~
|
||||
—— **已失效**。现改为:公开接口的改动必须满足 §八(只增不减、签名不改、模板接线)。
|
||||
- `internal/sdk` **不受冻结约束**,可自由扩展(此条仍成立);
|
||||
公开 SDK 接口指 `third_party/homeagent-sdk/sdk/`。
|
||||
- **公开接口的改动本身是 feature,不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
|
||||
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
|
||||
——发布分支冻结功能(§2.3),接口是最典型的功能面。
|
||||
@ -456,3 +461,52 @@ GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
|
||||
|
||||
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
||||
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
||||
|
||||
---
|
||||
|
||||
## 八、公开 SDK 接口的演进规则
|
||||
|
||||
> 本节原在《外部插件接口不变矩阵》(迁移期临时文档,已随迁移完成删除)§九。
|
||||
> 那份文档记的是**迁移期**的约束("换运行模型不动业务代码",靠
|
||||
> `git diff third_party/homeagent-sdk/sdk/` 为空来守)。迁移完成后该约束**到期**——
|
||||
> 继续冻结等于让 SDK 永远停在迁移那天的能力面,多模态这类功能永远到不了插件手上。
|
||||
> 取代它的是下面三条更弱、但仍然硬的规则。
|
||||
|
||||
### 1. 只增不减,签名不改
|
||||
|
||||
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
|
||||
|
||||
实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
|
||||
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
|
||||
而那些插件根本不关心媒体。
|
||||
|
||||
### 2. 新增方法必须是「插件调用、内核实现」方向
|
||||
|
||||
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增方法后,插件只是
|
||||
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
|
||||
每个存量插件都会因未实现而编译失败。
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的实现若不满足新接口,
|
||||
每个外部插件都**编不过**——是硬失败,不是软降级。
|
||||
|
||||
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
|
||||
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
|
||||
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
|
||||
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,漂移**不会被编译器抓到**。
|
||||
|
||||
### 4. 「接口纯追加」不等于「无需重编」
|
||||
|
||||
插件运行协议版本(`ProtocolVersion`)与 SDK 接口版本是**两件事**。
|
||||
协议升级(如 1.2.0 的 fd3 布局变更,不支持滚动升级)时,`ProtocolVersion` 不匹配
|
||||
会在握手时被明确拒绝并提示用配套 `hmapdev` 重编。
|
||||
必须把两者分开说,否则会被误读成"既然纯追加就还能用旧产物"。
|
||||
|
||||
### 5. 合回 main 前要同步的东西
|
||||
|
||||
1. 改动公开 SDK 接口面后,同步 SDK 仓的版本(§七)与 `SDKCompatibleVersion`;
|
||||
2. 生成模板已接线(跑 `cd tools/hmapdev && go test ./...`,含
|
||||
`TestProcTemplate_CoversAllCoreMethods`);
|
||||
3. 存量插件源码零改动(逐个 `cd example/<n> && go vet ./...`);
|
||||
4. 并发安全(`go test -race -count=5 ./sdk/`)。
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
//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(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
|
||||
}
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
//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✅ 卸载成功")
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V1"; }
|
||||
@ -1,2 +0,0 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V2"; }
|
||||
@ -1,9 +0,0 @@
|
||||
#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); }
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 10:多媒体 payload —— 共享内存零拷贝 vs JSON base64 ===")
|
||||
sizes := []int{100 * 1024, 1024 * 1024, 5 * 1024 * 1024}
|
||||
for _, sz := range sizes {
|
||||
img := make([]byte, sz)
|
||||
for i := range img { img[i] = byte(i % 251) }
|
||||
|
||||
// A. JSON + base64(当前 ContentBlock 的做法)
|
||||
t0 := time.Now()
|
||||
b64 := base64.StdEncoding.EncodeToString(img)
|
||||
blob, _ := json.Marshal(map[string]string{"type": "image_url", "url": "data:image/png;base64," + b64})
|
||||
var back map[string]string
|
||||
json.Unmarshal(blob, &back)
|
||||
dec, _ := base64.StdEncoding.DecodeString(back["url"][22:])
|
||||
jsonDur := time.Since(t0)
|
||||
|
||||
// B. 共享内存 arena(写入 + 偏移解引用,零拷贝读)
|
||||
mfd, _ := unix.MemfdCreate("arena", 0)
|
||||
unix.Ftruncate(mfd, int64(sz+4096))
|
||||
data, _ := unix.Mmap(mfd, 0, sz+4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
t0 = time.Now()
|
||||
copy(data[4096:], img) // 写 arena
|
||||
view := data[4096 : 4096+sz] // 偏移解引用 = 零拷贝切片
|
||||
_ = view[sz-1]
|
||||
shmDur := time.Since(t0)
|
||||
unix.Munmap(data)
|
||||
unix.Close(mfd)
|
||||
|
||||
fmt.Printf("\n%s payload:\n", map[int]string{100*1024:"100KB", 1024*1024:"1MB", 5*1024*1024:"5MB"}[sz])
|
||||
fmt.Printf(" A JSON+base64: %8v 传输体积 %d B (+%.0f%%) 解出 %d B %s\n",
|
||||
jsonDur, len(blob), float64(len(blob)-sz)/float64(sz)*100, len(dec),
|
||||
map[bool]string{true:"✓",false:"✗"}[len(dec)==sz])
|
||||
fmt.Printf(" B 共享内存: %8v 传输体积 8 B (描述符) 零拷贝视图 %d B\n", shmDur, len(view))
|
||||
fmt.Printf(" → 加速 %.0fx, 体积节省 %.0f%%\n",
|
||||
float64(jsonDur)/float64(shmDur), float64(len(blob)-8)/float64(len(blob))*100)
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
|
||||
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 11:工具调用 RPC 端到端延迟(实测 payload 中位 93B)===")
|
||||
cmd := exec.Command("./plug11")
|
||||
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
|
||||
cmd.Start()
|
||||
enc := json.NewEncoder(bufio.NewWriter(sin))
|
||||
w := bufio.NewWriter(sin); enc = json.NewEncoder(w)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
|
||||
args := json.RawMessage(`{"city":"hangzhou","days":3,"unit":"celsius","detail":true}`)
|
||||
const N = 10000
|
||||
lat := make([]time.Duration, 0, N)
|
||||
for i := 0; i < N; i++ {
|
||||
t0 := time.Now()
|
||||
enc.Encode(Req{ID: i, Method: "weather_query", Args: args}); w.Flush()
|
||||
var r Res
|
||||
if err := dec.Decode(&r); err != nil { break }
|
||||
lat = append(lat, time.Since(t0))
|
||||
}
|
||||
sin.Close(); cmd.Wait()
|
||||
sort.Slice(lat, func(a,b int) bool { return lat[a] < lat[b] })
|
||||
p := func(q float64) time.Duration { return lat[int(float64(len(lat))*q)] }
|
||||
fmt.Printf("样本 %d 次\n", len(lat))
|
||||
fmt.Printf(" p50 = %v\n p90 = %v\n p99 = %v\n max = %v\n", p(0.5), p(0.9), p(0.99), lat[len(lat)-1])
|
||||
fmt.Printf("\n对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ %.5f%%\n",
|
||||
float64(p(0.5))/float64(3*time.Second)*100)
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
import ("bufio";"encoding/json";"os")
|
||||
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
|
||||
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
|
||||
func main(){
|
||||
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
|
||||
for { var q Req
|
||||
if err:=dec.Decode(&q); err!=nil {return}
|
||||
enc.Encode(Res{ID:q.ID, Result:`{"ok":true,"data":"` + string(q.Args) + `"}`}); w.Flush() }
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func threads() int { e, _ := os.ReadDir("/proc/self/task"); return len(e) }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 1:eventfd 是否走 Go netpoller(只 park goroutine 不占 OS 线程)===")
|
||||
base := threads()
|
||||
fmt.Printf("基线线程数: %d (GOMAXPROCS=%d)\n\n", base, runtime.GOMAXPROCS(0))
|
||||
|
||||
const N = 200 // 模拟 200 个订阅者等待
|
||||
var wg sync.WaitGroup
|
||||
var woke int64
|
||||
files := make([]*os.File, N)
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
||||
if err != nil { fmt.Println("eventfd 失败:", err); return }
|
||||
f := os.NewFile(uintptr(efd), fmt.Sprintf("evt%d", i))
|
||||
files[i] = f
|
||||
wg.Add(1)
|
||||
go func(f *os.File) {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 8)
|
||||
// 阻塞读:若走 netpoller 只 park goroutine
|
||||
if _, err := f.Read(buf); err == nil {
|
||||
atomic.AddInt64(&woke, 1)
|
||||
}
|
||||
}(f)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond) // 让所有 goroutine 进入等待
|
||||
waiting := threads()
|
||||
fmt.Printf("%d 个 goroutine 阻塞在 eventfd.Read 后:\n", N)
|
||||
fmt.Printf(" 线程数 = %d (增长 %d)\n", waiting, waiting-base)
|
||||
if waiting-base < 20 {
|
||||
fmt.Println(" ✅ 走 netpoller:线程未随等待者数量增长")
|
||||
} else {
|
||||
fmt.Printf(" ❌ 退化为阻塞 syscall:每个等待者占一个 OS 线程\n")
|
||||
}
|
||||
|
||||
// 全部唤醒
|
||||
one := []byte{1,0,0,0,0,0,0,0}
|
||||
for _, f := range files { f.Write(one) }
|
||||
wg.Wait()
|
||||
fmt.Printf("\n唤醒数 = %d/%d 唤醒后线程数 = %d\n", woke, N, threads())
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 子进程:fd 3 = eventfd(通知), fd 4 = shm 文件
|
||||
func main() {
|
||||
efd := os.NewFile(3, "evt")
|
||||
shmf := os.NewFile(4, "shm")
|
||||
|
||||
data, err := unix.Mmap(int(shmf.Fd()), 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { fmt.Println("CHILD mmap 失败:", err); os.Exit(1) }
|
||||
fmt.Printf("CHILD: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
|
||||
|
||||
buf := make([]byte, 8)
|
||||
if _, err := efd.Read(buf); err != nil {
|
||||
fmt.Println("CHILD read err:", err); os.Exit(1)
|
||||
}
|
||||
n := binary.LittleEndian.Uint64(buf)
|
||||
fmt.Printf("CHILD: 被 eventfd 唤醒, 计数=%d\n", n)
|
||||
|
||||
// 按偏移读:头部 16 字节 = {off uint32, len uint32, seq uint64}
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
seq := binary.LittleEndian.Uint64(data[8:16])
|
||||
payload := string(data[off : off+ln])
|
||||
fmt.Printf("CHILD: 偏移解引用 off=%d len=%d seq=%d → %q\n", off, ln, seq, payload)
|
||||
|
||||
// 子进程回写(验证双向可见)
|
||||
copy(data[2048:], []byte("CHILD-ACK"))
|
||||
binary.LittleEndian.PutUint32(data[16:20], 2048)
|
||||
binary.LittleEndian.PutUint32(data[20:24], uint32(len("CHILD-ACK")))
|
||||
fmt.Println("CHILD: 已回写 ACK")
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 2:跨进程 eventfd 通知 + 共享内存偏移解引用 ===")
|
||||
|
||||
// eventfd 不带 CLOEXEC(需要被子进程继承)
|
||||
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK)
|
||||
if err != nil { panic(err) }
|
||||
evtFile := os.NewFile(uintptr(efd), "evt")
|
||||
|
||||
// shm: 用 memfd(匿名,无需 /dev/shm 清理)
|
||||
mfd, err := unix.MemfdCreate("stagectx", 0)
|
||||
if err != nil { panic(err) }
|
||||
if err := unix.Ftruncate(mfd, 4096); err != nil { panic(err) }
|
||||
shmFile := os.NewFile(uintptr(mfd), "shm")
|
||||
|
||||
data, err := unix.Mmap(mfd, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { panic(err) }
|
||||
fmt.Printf("PARENT: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
|
||||
|
||||
// 写 payload 到 arena(偏移 1024),头部记描述符
|
||||
msg := "hello-from-parent-via-offset"
|
||||
copy(data[1024:], []byte(msg))
|
||||
binary.LittleEndian.PutUint32(data[0:4], 1024)
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(msg)))
|
||||
binary.LittleEndian.PutUint64(data[8:16], 42)
|
||||
fmt.Printf("PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:%d, seq:42}\n", len(msg))
|
||||
|
||||
cmd := exec.Command("go", "run", "exp2_child.go")
|
||||
cmd.ExtraFiles = []*os.File{evtFile, shmFile} // → 子进程 fd 3, 4
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Start(); err != nil { panic(err) }
|
||||
|
||||
time.Sleep(3 * time.Second) // 等 go run 编译+启动
|
||||
fmt.Println("PARENT: 数据到位后 post eventfd(不等待消费者)")
|
||||
t0 := time.Now()
|
||||
evtFile.Write([]byte{1,0,0,0,0,0,0,0})
|
||||
fmt.Printf("PARENT: post 耗时 %v ← post-and-forget\n", time.Since(t0))
|
||||
|
||||
cmd.Wait()
|
||||
|
||||
// 读子进程回写
|
||||
off := binary.LittleEndian.Uint32(data[16:20])
|
||||
ln := binary.LittleEndian.Uint32(data[20:24])
|
||||
if ln > 0 {
|
||||
fmt.Printf("PARENT: 读到子进程回写 → %q ✅ 双向可见\n", string(data[off:off+ln]))
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
|
||||
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
|
||||
|
||||
func main() {
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc, dec := json.NewEncoder(out), json.NewDecoder(in)
|
||||
|
||||
const N = 20000
|
||||
t0 := time.Now()
|
||||
for i := 0; i < N; i++ {
|
||||
enc.Encode(req{ID: i, Method: "stage.lock"})
|
||||
out.Flush()
|
||||
var r resp
|
||||
if err := dec.Decode(&r); err != nil { fmt.Fprintln(os.Stderr, "dec:", err); return }
|
||||
}
|
||||
d := time.Since(t0)
|
||||
fmt.Fprintf(os.Stderr, "CHILD: %d 次 lock RPC 往返 用时 %v, 均摊 %.2f µs/次\n",
|
||||
N, d, float64(d.Microseconds())/float64(N))
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
|
||||
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 3:锁仲裁 RPC 往返成本(stdio JSON-RPC)===")
|
||||
cmd := exec.Command("go", "run", "exp3_child.go")
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Start()
|
||||
|
||||
var mu sync.Mutex // 内核侧真实的锁仲裁
|
||||
dec := json.NewDecoder(bufio.NewReader(stdout))
|
||||
w := bufio.NewWriter(stdin)
|
||||
enc := json.NewEncoder(w)
|
||||
for {
|
||||
var q req
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
mu.Lock() // 真实加锁
|
||||
mu.Unlock() // 立即释放(模拟仲裁开销)
|
||||
enc.Encode(resp{ID: q.ID, OK: true})
|
||||
w.Flush()
|
||||
}
|
||||
cmd.Wait()
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type ring struct {
|
||||
writeSeq atomic.Uint64
|
||||
cap uint64
|
||||
slots []uint64
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 4:事件环 post-and-forget vs 同步 Publish(慢消费者场景)===")
|
||||
const tokens = 5000
|
||||
|
||||
// --- A. 现状:同步 Publish,消费者慢 ---
|
||||
slowHandler := func() { time.Sleep(20 * time.Microsecond) }
|
||||
t0 := time.Now()
|
||||
for i := 0; i < tokens; i++ { slowHandler() }
|
||||
syncDur := time.Since(t0)
|
||||
fmt.Printf("A 同步 Publish (慢消费者 20µs): %d token 耗时 %v → 均摊 %.1f µs/token\n",
|
||||
tokens, syncDur, float64(syncDur.Microseconds())/tokens)
|
||||
|
||||
// --- B. 新方案:写环 + eventfd post,不等消费者 ---
|
||||
r := &ring{cap: 1024, slots: make([]uint64, 1024)}
|
||||
efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK)
|
||||
f := os.NewFile(uintptr(efd), "e")
|
||||
|
||||
var dropped atomic.Uint64
|
||||
// 慢消费者 goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
buf := make([]byte, 8)
|
||||
var readSeq uint64
|
||||
for {
|
||||
if _, err := f.Read(buf); err != nil { return }
|
||||
w := r.writeSeq.Load()
|
||||
if w-readSeq > r.cap {
|
||||
dropped.Add(w - readSeq - r.cap)
|
||||
readSeq = w - r.cap
|
||||
}
|
||||
for readSeq < w { readSeq++ }
|
||||
time.Sleep(20 * time.Microsecond) // 慢
|
||||
select { case <-done: return; default: }
|
||||
}
|
||||
}()
|
||||
|
||||
t0 = time.Now()
|
||||
one := []byte{1,0,0,0,0,0,0,0}
|
||||
for i := 0; i < tokens; i++ {
|
||||
s := r.writeSeq.Add(1)
|
||||
r.slots[s%r.cap] = s // 写数据
|
||||
f.Write(one) // post,不等
|
||||
}
|
||||
asyncDur := time.Since(t0)
|
||||
close(done)
|
||||
fmt.Printf("B 环+eventfd post: %d token 耗时 %v → 均摊 %.2f µs/token\n",
|
||||
tokens, asyncDur, float64(asyncDur.Microseconds())/tokens)
|
||||
fmt.Printf("\n加速比 %.1fx 丢弃事件 %d(消费者跟不上,已计数)\n",
|
||||
float64(syncDur)/float64(asyncDur), dropped.Load())
|
||||
if asyncDur < syncDur/5 {
|
||||
fmt.Println("✅ post-and-forget 使流式发布与消费者速度解耦")
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
|
||||
if err != nil { return 0 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "Pss:") {
|
||||
f := strings.Fields(l)
|
||||
n, _ := strconv.Atoi(f[1]); return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func threads(pid int) int {
|
||||
e, _ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e)
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 5:17 个 Go 子进程插件的真实常驻开销(PSS 计入共享页去重)===")
|
||||
var cmds []*exec.Cmd
|
||||
for i := 0; i < 17; i++ {
|
||||
c := exec.Command("./plugbin")
|
||||
c.Stdin, _ = os.Open(os.DevNull)
|
||||
if err := c.Start(); err != nil { fmt.Println("start:", err); return }
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
totalPss, totalThreads := 0, 0
|
||||
for _, c := range cmds {
|
||||
totalPss += pssKB(c.Process.Pid)
|
||||
totalThreads += threads(c.Process.Pid)
|
||||
}
|
||||
fmt.Printf("17 进程合计: PSS = %.1f MB, 线程 = %d\n", float64(totalPss)/1024, totalThreads)
|
||||
fmt.Printf("单进程均摊: PSS = %.2f MB, 线程 = %.1f\n",
|
||||
float64(totalPss)/1024/17, float64(totalThreads)/17)
|
||||
fmt.Printf("\n对照 homed 当前(单进程装 17 个 .so):\n")
|
||||
// 找 homed
|
||||
out, _ := exec.Command("pgrep", "-x", "homed").Output()
|
||||
if p := strings.TrimSpace(string(out)); p != "" {
|
||||
pid, _ := strconv.Atoi(strings.Fields(p)[0])
|
||||
fmt.Printf(" homed PSS = %.1f MB, 线程 = %d\n", float64(pssKB(pid))/1024, threads(pid))
|
||||
}
|
||||
for _, c := range cmds { c.Process.Kill(); c.Wait() }
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// 模拟一个最小插件:stdio JSON-RPC loop + 一个 goroutine
|
||||
func main() {
|
||||
go func() { select {} }()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
dec := json.NewDecoder(in)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(out)
|
||||
for {
|
||||
var m map[string]interface{}
|
||||
if err := dec.Decode(&m); err != nil { return }
|
||||
enc.Encode(map[string]interface{}{"ok": true})
|
||||
out.Flush()
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
|
||||
if err != nil { return -1 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "Pss:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func rssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err != nil { return -1 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "VmRSS:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func threads(pid int) int { e,_ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e) }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 5b:17 个 Go 子进程常驻开销(保持 stdin 管道存活)===")
|
||||
var cmds []*exec.Cmd
|
||||
var pipes []interface{ Close() error }
|
||||
for i := 0; i < 17; i++ {
|
||||
c := exec.Command("./plugbin")
|
||||
w, _ := c.StdinPipe() // 保持打开 → 不 EOF
|
||||
pipes = append(pipes, w)
|
||||
c.Stdout = nil
|
||||
if err := c.Start(); err != nil { fmt.Println(err); return }
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tp, tr, tt, alive := 0, 0, 0, 0
|
||||
for _, c := range cmds {
|
||||
pid := c.Process.Pid
|
||||
if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); err != nil { continue }
|
||||
alive++
|
||||
if v := pssKB(pid); v > 0 { tp += v }
|
||||
if v := rssKB(pid); v > 0 { tr += v }
|
||||
tt += threads(pid)
|
||||
}
|
||||
fmt.Printf("存活进程 %d/17\n", alive)
|
||||
fmt.Printf("合计: PSS=%.1f MB RSS=%.1f MB 线程=%d\n",
|
||||
float64(tp)/1024, float64(tr)/1024, tt)
|
||||
if alive > 0 {
|
||||
fmt.Printf("均摊: PSS=%.2f MB RSS=%.2f MB 线程=%.1f\n",
|
||||
float64(tp)/1024/float64(alive), float64(tr)/1024/float64(alive), float64(tt)/float64(alive))
|
||||
}
|
||||
out, _ := exec.Command("pgrep", "-x", "homed").Output()
|
||||
if p := strings.TrimSpace(string(out)); p != "" {
|
||||
pid, _ := strconv.Atoi(strings.Fields(p)[0])
|
||||
fmt.Printf("\n对照 homed(单进程 + 17 个 .so): RSS=%.1f MB 线程=%d\n",
|
||||
float64(rssKB(pid))/1024, threads(pid))
|
||||
}
|
||||
for _, c := range cmds { c.Process.Kill(); c.Wait() }
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 6:子进程崩溃隔离 + 退出码/EOF 作为 recordCrash 信号 ===")
|
||||
cmd := exec.Command("./crashbin")
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = nil // 丢弃 panic 栈
|
||||
cmd.Start()
|
||||
fmt.Printf("插件进程 pid=%d 已启动\n", cmd.Process.Pid)
|
||||
|
||||
enc := json.NewEncoder(sin)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
|
||||
// 正常调用
|
||||
enc.Encode(map[string]string{"method": "ping"})
|
||||
var r map[string]interface{}
|
||||
if err := dec.Decode(&r); err == nil { fmt.Println("正常调用 → ", r) }
|
||||
|
||||
// 触发崩溃
|
||||
fmt.Println("\n发送 boom(插件内 panic)...")
|
||||
t0 := time.Now()
|
||||
enc.Encode(map[string]string{"method": "boom"})
|
||||
err := dec.Decode(&r)
|
||||
|
||||
detected := "未检测到"
|
||||
if errors.Is(err, io.EOF) || err == io.ErrUnexpectedEOF { detected = "EOF" } else if err != nil { detected = fmt.Sprintf("%v", err) }
|
||||
fmt.Printf("调用侧感知: %s (耗时 %v)\n", detected, time.Since(t0))
|
||||
|
||||
werr := cmd.Wait()
|
||||
var ec int = -1
|
||||
if ee, ok := werr.(*exec.ExitError); ok { ec = ee.ExitCode() }
|
||||
fmt.Printf("进程退出码 = %d (panic → 2,可直接喂 recordCrash)\n", ec)
|
||||
|
||||
fmt.Printf("\n宿主进程仍存活: pid=%d ✅ 崩溃已隔离\n", os.Getpid())
|
||||
fmt.Println("→ 对照:当前 .so 模型下,bridge 兜不住的 panic 会带崩整个 homed")
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(out)
|
||||
for {
|
||||
var m map[string]interface{}
|
||||
if err := dec.Decode(&m); err != nil { return }
|
||||
if m["method"] == "boom" {
|
||||
panic("插件故意崩溃") // 真 panic
|
||||
}
|
||||
enc.Encode(map[string]interface{}{"ok": true})
|
||||
out.Flush()
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func spawnAndAsk(bin string) string {
|
||||
cmd := exec.Command(bin)
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Start()
|
||||
enc := json.NewEncoder(sin)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
enc.Encode(map[string]string{"method": "version"})
|
||||
var r map[string]interface{}
|
||||
dec.Decode(&r)
|
||||
sin.Close()
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
if v, ok := r["version"].(string); ok { return v }
|
||||
return "?"
|
||||
}
|
||||
|
||||
func build(ver, out string) {
|
||||
src := fmt.Sprintf(`package main
|
||||
import ("bufio";"encoding/json";"os")
|
||||
func main(){
|
||||
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
|
||||
for { var m map[string]interface{}
|
||||
if err:=dec.Decode(&m); err!=nil {return}
|
||||
enc.Encode(map[string]string{"version":%q}); w.Flush() }
|
||||
}`, ver)
|
||||
os.MkdirAll("v", 0755)
|
||||
os.WriteFile("v/main.go", []byte(src), 0644)
|
||||
os.WriteFile("v/go.mod", []byte("module v\ngo 1.21\n"), 0644)
|
||||
c := exec.Command("go", "build", "-o", "../"+out, ".")
|
||||
c.Dir = "v"
|
||||
if b, err := c.CombinedOutput(); err != nil { fmt.Println("build err:", string(b)) }
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 7:子进程模型下的热重载(迁移的原始目标)===")
|
||||
build("v1.0.0", "hotbin")
|
||||
fmt.Printf("1) 首次启动插件 → version = %s\n", spawnAndAsk("./hotbin"))
|
||||
|
||||
fmt.Println("2) 替换二进制为 v2.0.0(同路径,无需版本化 hash 目录)")
|
||||
build("v2.0.0", "hotbin")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
v := spawnAndAsk("./hotbin")
|
||||
fmt.Printf("3) 重启插件进程 → version = %s\n", v)
|
||||
if v == "v2.0.0" {
|
||||
fmt.Println("\n✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏")
|
||||
fmt.Println(" 对照 .so 模型:同路径 dlopen 复用旧映像,永远拿不到 v2")
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 8:跨进程并发扇出改写同一 StageContext(最高风险点 3.4)===")
|
||||
|
||||
mfd, _ := unix.MemfdCreate("stagectx", 0)
|
||||
unix.Ftruncate(mfd, 65536)
|
||||
shmFile := os.NewFile(uintptr(mfd), "shm")
|
||||
data, _ := unix.Mmap(mfd, 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
|
||||
// 初始 final_text = "" @1024, arena 游标 = 1024
|
||||
binary.LittleEndian.PutUint32(data[0:4], 1024)
|
||||
binary.LittleEndian.PutUint32(data[4:8], 0)
|
||||
binary.LittleEndian.PutUint32(data[8:12], 1024)
|
||||
|
||||
tags := []string{"A", "B", "C", "D", "E"} // 5 个并发插件
|
||||
var mu sync.Mutex // 内核侧锁仲裁
|
||||
var wg sync.WaitGroup
|
||||
var rpcCount int64
|
||||
var cntMu sync.Mutex
|
||||
|
||||
t0 := time.Now()
|
||||
for _, tag := range tags {
|
||||
cmd := exec.Command("go", "run", "exp8_worker.go", tag)
|
||||
cmd.ExtraFiles = []*os.File{shmFile}
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Start()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
w := bufio.NewWriter(sin)
|
||||
enc := json.NewEncoder(w)
|
||||
held := false
|
||||
for {
|
||||
var q map[string]string
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
switch q["method"] {
|
||||
case "stage.lock": mu.Lock(); held = true
|
||||
case "stage.unlock": if held { mu.Unlock(); held = false }
|
||||
}
|
||||
cntMu.Lock(); rpcCount++; cntMu.Unlock()
|
||||
enc.Encode(map[string]bool{"ok": true}); w.Flush()
|
||||
}
|
||||
if held { mu.Unlock() }
|
||||
cmd.Wait()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
dur := time.Since(t0)
|
||||
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
final := string(data[off : off+ln])
|
||||
|
||||
fmt.Printf("\n--- 结果 ---\n")
|
||||
fmt.Printf("最终 final_text 长度 = %d\n", len(final))
|
||||
counts := map[string]int{}
|
||||
for _, t := range tags { counts[t] = strings.Count(final, t) }
|
||||
fmt.Printf("各插件写入次数: %v\n", counts)
|
||||
total := 0
|
||||
for _, c := range counts { total += c }
|
||||
fmt.Printf("总字符 = %d, 长度 = %d → %s\n", total, len(final),
|
||||
map[bool]string{true:"一致 ✅ 无丢失/无撕裂", false:"不一致 ❌"}[total == len(final)])
|
||||
fmt.Printf("RPC 锁操作 = %d 次, 总耗时 %v\n", rpcCount, dur)
|
||||
fmt.Printf("\n注:写入次数少于 5×300 是 arena 64KB 上限所致(append-only 未压实),符合设计\n")
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 模拟插件:拿锁 → 读 final_text → 追加自己的标记 → 写回 → 放锁
|
||||
// 锁通过 stdio RPC 向内核申请(方案 3.7:锁仲裁回归内核,无 cgo)
|
||||
func main() {
|
||||
tag := os.Args[1]
|
||||
shmf := os.NewFile(3, "shm")
|
||||
data, err := unix.Mmap(int(shmf.Fd()), 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { fmt.Fprintln(os.Stderr, "mmap:", err); os.Exit(1) }
|
||||
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(w)
|
||||
rpc := func(method string) {
|
||||
enc.Encode(map[string]string{"method": method}); w.Flush()
|
||||
var r map[string]interface{}; dec.Decode(&r)
|
||||
}
|
||||
|
||||
const iters = 300
|
||||
for i := 0; i < iters; i++ {
|
||||
rpc("stage.lock")
|
||||
// --- 临界区:偏移解引用读写 final_text ---
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
cur := string(data[off : off+ln])
|
||||
add := tag
|
||||
newS := cur + add
|
||||
// append-only arena:写到新位置
|
||||
newOff := binary.LittleEndian.Uint32(data[8:12])
|
||||
if int(newOff)+len(newS) > 65536 { rpc("stage.unlock"); break }
|
||||
copy(data[newOff:], []byte(newS))
|
||||
binary.LittleEndian.PutUint32(data[0:4], newOff)
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(newS)))
|
||||
binary.LittleEndian.PutUint32(data[8:12], newOff+uint32(len(newS)))
|
||||
rpc("stage.unlock")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "worker "+tag+" done, iters="+strconv.Itoa(iters))
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func run(name, arg string, mu *sync.Mutex, crashed *bool) {
|
||||
cmd := exec.Command("go", "run", "exp9_worker.go", arg)
|
||||
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = nil
|
||||
cmd.Start()
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
w := bufio.NewWriter(sin); enc := json.NewEncoder(w)
|
||||
held := false
|
||||
for {
|
||||
var q map[string]string
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
switch q["method"] {
|
||||
case "stage.lock": mu.Lock(); held = true; fmt.Printf(" [%s] 获得锁\n", name)
|
||||
case "stage.unlock": if held { mu.Unlock(); held = false; fmt.Printf(" [%s] 释放锁\n", name) }
|
||||
}
|
||||
enc.Encode(map[string]bool{"ok":true}); w.Flush()
|
||||
}
|
||||
err := cmd.Wait()
|
||||
// 关键:进程死了,内核侧检测到 EOF/退出 → 强制释放它持有的锁
|
||||
if held {
|
||||
mu.Unlock()
|
||||
*crashed = true
|
||||
fmt.Printf(" [%s] 进程死亡(%v),内核强制释放其持有的锁 ← 自愈\n", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 9:持锁进程崩溃后的自愈(验证无需 robust pthread_mutex)===")
|
||||
var mu sync.Mutex
|
||||
crashed := false
|
||||
|
||||
fmt.Println("\n1) 插件 X 拿锁后 panic:")
|
||||
run("X", "crash", &mu, &crashed)
|
||||
|
||||
fmt.Println("\n2) 插件 Y 随后申请同一把锁:")
|
||||
done := make(chan bool, 1)
|
||||
go func() { run("Y", "normal", &mu, new(bool)); done <- true }()
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Println("\n✅ Y 正常获得并释放锁 —— 无死锁")
|
||||
fmt.Println(" → 内核持有锁的所有权,进程死亡由 Wait()/EOF 检测并强制释放")
|
||||
fmt.Println(" → 不需要 PTHREAD_PROCESS_SHARED|ROBUST,也不需要处理 EOWNERDEAD")
|
||||
fmt.Println(" → 整个架构可做到零 cgo")
|
||||
case <-time.After(15 * time.Second):
|
||||
fmt.Println("\n❌ 死锁:Y 拿不到锁(说明需要 robust 语义)")
|
||||
}
|
||||
_ = crashed
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import ("bufio";"encoding/json";"os")
|
||||
func main() {
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w := bufio.NewWriter(os.Stdout); enc := json.NewEncoder(w)
|
||||
rpc := func(m string) { enc.Encode(map[string]string{"method":m}); w.Flush(); var r map[string]interface{}; dec.Decode(&r) }
|
||||
rpc("stage.lock")
|
||||
if os.Args[1] == "crash" { panic("持锁时崩溃") } // 拿着锁死掉
|
||||
rpc("stage.unlock")
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 完全复刻内核 loader.go case 2 + templates.go go_invoke_stage 的链路
|
||||
type StageCtx struct {
|
||||
mu sync.RWMutex
|
||||
LLMText string
|
||||
ToolRes []string
|
||||
}
|
||||
|
||||
func (c *StageCtx) Lock() { c.mu.Lock() }
|
||||
func (c *StageCtx) Unlock() { c.mu.Unlock() }
|
||||
func (c *StageCtx) RLock() { c.mu.RLock() }
|
||||
func (c *StageCtx) RUnlock() { c.mu.RUnlock() }
|
||||
|
||||
// === 模拟外部插件(副本模型)===
|
||||
func externalPlugin(tag string, ctxJSON string) string {
|
||||
// go_invoke_stage: 新建全新对象
|
||||
sc := &StageCtx{}
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal([]byte(ctxJSON), &m)
|
||||
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
|
||||
|
||||
// 插件 handler:ctx.Lock() 锁的是这个新对象 → 空转
|
||||
sc.Lock()
|
||||
sc.LLMText = sc.LLMText + "[" + tag + "]"
|
||||
sc.Unlock()
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// === 模拟内核 case 2 handler ===
|
||||
func kernelStageHandler(sc *StageCtx, tag string) {
|
||||
sc.RLock()
|
||||
snap, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
|
||||
sc.RUnlock()
|
||||
|
||||
result := externalPlugin(tag, string(snap))
|
||||
|
||||
// applyStageResult
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &m)
|
||||
sc.Lock()
|
||||
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
|
||||
sc.Unlock()
|
||||
}
|
||||
|
||||
// === 内置插件:直接改同一对象 ===
|
||||
func nativePlugin(sc *StageCtx, tag string) {
|
||||
sc.Lock()
|
||||
sc.LLMText = sc.LLMText + "[" + tag + "]"
|
||||
sc.Unlock()
|
||||
}
|
||||
|
||||
func runCase(name string, fn func(*StageCtx, string), tags []string, rounds int) {
|
||||
lost := 0
|
||||
for r := 0; r < rounds; r++ {
|
||||
sc := &StageCtx{LLMText: "BASE"}
|
||||
var wg sync.WaitGroup
|
||||
for _, t := range tags {
|
||||
wg.Add(1)
|
||||
go func(t string) { defer wg.Done(); fn(sc, t) }(t)
|
||||
}
|
||||
wg.Wait()
|
||||
// 检查是否所有 tag 都在
|
||||
for _, t := range tags {
|
||||
if !strings.Contains(sc.LLMText, "["+t+"]") { lost++; break }
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %-28s %d/%d 轮出现修改丢失 (%.1f%%)\n", name, lost, rounds, float64(lost)/float64(rounds)*100)
|
||||
}
|
||||
|
||||
func main() {
|
||||
tags := []string{"A", "B", "C", "D", "E"}
|
||||
fmt.Println("5 个插件并发在 StageBeforeToolcall 追加标记,各 2000 轮:")
|
||||
fmt.Println()
|
||||
runCase("内置插件(共享同一对象)", nativePlugin, tags, 2000)
|
||||
runCase("外部插件(快照-副本-写回)", kernelStageHandler, tags, 2000)
|
||||
fmt.Println()
|
||||
fmt.Println("→ 副本模型下 read-modify-write 非原子:快照与写回之间的窗口导致覆盖")
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
// 精确复刻现网 AfterToolcall 上 sanitizer(Global,改写) + weather(OwnTools,只读) 的并发
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
type Ctx struct {
|
||||
mu sync.RWMutex
|
||||
ToolRes []ToolResult
|
||||
}
|
||||
func (c *Ctx) Lock(){c.mu.Lock()}; func (c *Ctx) Unlock(){c.mu.Unlock()}
|
||||
func (c *Ctx) RLock(){c.mu.RLock()}; func (c *Ctx) RUnlock(){c.mu.RUnlock()}
|
||||
|
||||
func cleanText(s string) string {
|
||||
// 模拟 sanitizer:去掉 ANSI/坏字节
|
||||
return strings.ReplaceAll(s, "\x1b[31m", "")
|
||||
}
|
||||
|
||||
// 内核 case 2 handler(外部插件通用路径)
|
||||
func kernelExternal(sc *Ctx, pluginFn func(*Ctx)) {
|
||||
// 1. 快照
|
||||
sc.RLock()
|
||||
snap, _ := json.Marshal(map[string]interface{}{"tool_results": sc.ToolRes})
|
||||
sc.RUnlock()
|
||||
|
||||
// 2. go_invoke_stage: 插件进程内全新对象
|
||||
local := &Ctx{}
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(snap, &m)
|
||||
if v, ok := m["tool_results"]; ok {
|
||||
b, _ := json.Marshal(v)
|
||||
json.Unmarshal(b, &local.ToolRes)
|
||||
}
|
||||
|
||||
// 3. 插件 handler 跑在副本上
|
||||
pluginFn(local)
|
||||
|
||||
// 4. stageContextWritable: 无条件回传 tool_results
|
||||
out := map[string]interface{}{}
|
||||
if len(local.ToolRes) > 0 { out["tool_results"] = local.ToolRes }
|
||||
rb, _ := json.Marshal(out)
|
||||
|
||||
// 5. applyStageResult 写回内核
|
||||
var rm map[string]interface{}
|
||||
json.Unmarshal(rb, &rm)
|
||||
sc.Lock()
|
||||
if v, ok := rm["tool_results"]; ok {
|
||||
b, _ := json.Marshal(v)
|
||||
var trs []ToolResult
|
||||
if json.Unmarshal(b, &trs) == nil { sc.ToolRes = trs }
|
||||
}
|
||||
sc.Unlock()
|
||||
}
|
||||
|
||||
func sanitizerStage(ctx *Ctx) {
|
||||
ctx.Lock(); defer ctx.Unlock()
|
||||
for i, tr := range ctx.ToolRes {
|
||||
if s, ok := tr.Result.(string); ok {
|
||||
ctx.ToolRes[i].Result = cleanText(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
func weatherStage(ctx *Ctx) {
|
||||
ctx.Lock(); defer ctx.Unlock()
|
||||
// 只读打印,不改(own_tools scope 已匹配)
|
||||
_ = len(ctx.ToolRes)
|
||||
}
|
||||
|
||||
func main() {
|
||||
const rounds = 3000
|
||||
dirty := "\x1b[31m晴 25°C"
|
||||
polluted := 0
|
||||
for r := 0; r < rounds; r++ {
|
||||
sc := &Ctx{ToolRes: []ToolResult{{Name:"weather_query", Plugin:"weather", Result: dirty}}}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func(){ defer wg.Done(); kernelExternal(sc, sanitizerStage) }()
|
||||
go func(){ defer wg.Done(); kernelExternal(sc, weatherStage) }()
|
||||
wg.Wait()
|
||||
if s, ok := sc.ToolRes[0].Result.(string); ok && strings.Contains(s, "\x1b[31m") {
|
||||
polluted++
|
||||
}
|
||||
}
|
||||
fmt.Printf("现网场景复刻:模型调用 weather_query,sanitizer+weather 并发跑 AfterToolcall\n")
|
||||
fmt.Printf(" %d 轮中 %d 轮清洗结果被覆盖 (%.1f%%)\n", rounds, polluted, float64(polluted)/rounds*100)
|
||||
if polluted > 0 {
|
||||
fmt.Printf("\n ⚠️ 确认:weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果\n")
|
||||
fmt.Printf(" → 脏数据(ANSI 转义)进入 LLM 上下文\n")
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
//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 回收全部资源")
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
//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 线程),无法被抢占或回收")
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
#include <unistd.h>
|
||||
void hang_forever(void) { while(1) sleep(1); }
|
||||
@ -1,137 +0,0 @@
|
||||
# 实验 19:迁移验证工具(Part 6.3)
|
||||
|
||||
外部插件从 C ABI 动态库迁移到子进程后的批量重编与开销实测工具。
|
||||
与 01~18 的性质不同:那些是**决策前**的可行性验证,这两个是**迁移执行期**
|
||||
反复使用的操作脚本。
|
||||
|
||||
## rebuild-plugins.sh
|
||||
|
||||
批量把 `example/` 下的插件重编为子进程模式(`plugin.bin`)。
|
||||
|
||||
```bash
|
||||
PLUGINDEV=/tmp/plugindev ./rebuild-plugins.sh weather sanitizer qq
|
||||
```
|
||||
|
||||
关键性质:**不修改任何插件源码**。`plg.json` 的 `entry` 仍写着 `"plugin.so"`
|
||||
也无妨——工具链已不看这个字段(Part 6.1)。
|
||||
|
||||
两个实现细节值得记:
|
||||
|
||||
- **成功判定看产物而非退出码**。plugindev 对部分错误只 `fmt.Printf` 不
|
||||
`os.Exit`,单看 `$?` 会把失败当成功。
|
||||
- 构建前清 `build/`+`dist/`。残留的 `.so` 不影响构建,但会让人误以为
|
||||
还在用旧通道。
|
||||
|
||||
已知环境依赖:`rss` 插件需要 `github.com/mmcdole/gofeed`,
|
||||
`proxy.golang.org` 不通时用 `GOPROXY=https://goproxy.cn,direct`。
|
||||
|
||||
## measure-plugin-overhead.sh
|
||||
|
||||
实测 homed + 插件子进程的常驻开销。
|
||||
|
||||
```bash
|
||||
./measure-plugin-overhead.sh $(pgrep -f 'homed -data' | head -1)
|
||||
```
|
||||
|
||||
### 一个统计口径的坑
|
||||
|
||||
第一版混用了两个来源:RSS 读 `/proc/pid/status` 的 `VmRSS`,
|
||||
PSS 读 `smaps_rollup` 的 `Pss`。结果输出 `PSS=87.9MB > RSS=69.1MB`——
|
||||
物理上不可能。
|
||||
|
||||
原因是两者对**共享内存段**的计入方式不同:`smaps_rollup` 的 `Rss` 含
|
||||
`Pss_Shmem`(共享段的按比例份额),`VmRSS` 不含。现已统一从
|
||||
`smaps_rollup` 读,保证 PSS ≤ RSS。
|
||||
|
||||
### 实测结果(2026-09-02,15 个真实插件)
|
||||
|
||||
```
|
||||
15 个插件进程 RSS=88.0 MB PSS=87.9 MB 线程=82
|
||||
均摊 5.87 MB 5.86 MB 5.5 线程
|
||||
homed 本体 RSS=182 MB 线程=15
|
||||
```
|
||||
|
||||
**与实验 5 基线(17 进程 RSS=29.1MB / PSS=12.9MB / 线程=84)的偏差解释**:
|
||||
|
||||
实验 5 用的是 2.68MB 的最小插件,真实插件 3.1~14.8MB(browser 依赖最多)。
|
||||
RSS 随二进制体积线性增长,故绝对数字不可比。可比的是结构性指标:
|
||||
|
||||
| 指标 | 基线 | 实测 | 判断 |
|
||||
|---|---|---|---|
|
||||
| 均摊线程 | 4.9 | 5.5 | 同量级,无线程膨胀 |
|
||||
| PSS/RSS | 44% | 99.9% | **明显差于基线** |
|
||||
|
||||
第二项是真实发现:基线里 PSS 远低于 RSS,说明 Go runtime 只读代码页在
|
||||
进程间共享。实测几乎不共享,因为 15 个插件是 15 个**不同**的二进制,
|
||||
没有共同的物理页可映射。
|
||||
|
||||
这是「每插件独立二进制」的固有代价,不是缺陷,但意味着实际内存开销
|
||||
高于评估文档(§4.3)的乐观估计。若日后需要压这一项,方向是让插件共享
|
||||
一个 launcher 二进制 + 各自的业务 plugin,而非各自静态链接整个 runtime。
|
||||
|
||||
## 冒烟测试
|
||||
|
||||
自动化部分在 `internal/plugins/real_plugin_smoke_test.go`(4 项):
|
||||
|
||||
- `ToolInvokeRoundTrip`:工具真实调用往返(不只是注册)
|
||||
- `StageRewriteTakesEffect`:sanitizer 改写型 stage 在真实内核装配下生效
|
||||
- `MultiPluginShareOneSegment`:多插件共享一段,只读插件不覆盖改写结果
|
||||
- `CrashDoesNotKillKernel`:SIGKILL 插件进程,homed 存活
|
||||
|
||||
这些测试用**真实 example 产物**而非 testdata 假插件,且 manifest 刻意写
|
||||
`"entry":"plugin.so"`——验证「业务代码零改动」这一承诺在完整内核装配下成立。
|
||||
未重编时 skip 而非 fail,CI 不强制先跑重编脚本。
|
||||
|
||||
## 压测与延迟(Part 6.6 验收)
|
||||
|
||||
基准与压测在代码里而非独立脚本:
|
||||
`internal/plugin/proc/bench_test.go` + `streaming_test.go`。
|
||||
|
||||
```bash
|
||||
go test -run '^$' -bench . ./internal/plugin/proc/
|
||||
go test -run 'TestStreaming_' -v ./internal/plugin/proc/
|
||||
```
|
||||
|
||||
### 实测(2026-09-02,AMD Ryzen 7 7840HS)
|
||||
|
||||
| 项目 | 实测 | 基线 | 判断 |
|
||||
|---|---|---|---|
|
||||
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
|
||||
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
|
||||
| 事件环写入 | 95 ns | — | 亚微秒 |
|
||||
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
|
||||
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
|
||||
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
|
||||
|
||||
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——两者测的不是同一个东西:
|
||||
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
|
||||
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级(那部分是 RPC 往返)。
|
||||
基准原名 `BenchmarkStageLockRoundTrip` 有误导性,已改为
|
||||
`BenchmarkStageLockArbitration`。
|
||||
|
||||
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs(2.8%),
|
||||
其余是**一次 stage 要走 3 次进程间往返**——`stage.invoke` 加上插件侧反向的
|
||||
`stage.lock` / `stage.unlock`。相对 LLM 往返 2-8 秒可忽略;若日后要优化,
|
||||
方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答,省掉两次往返。
|
||||
|
||||
### 流式压测(§4.3 标记「风险高」的那一项)
|
||||
|
||||
原文的担忧:「`Bus.Publish` 路径禁用任何锁/阻塞——流式输出逐 token 发布,
|
||||
任何等待都会卡顿」。
|
||||
|
||||
```
|
||||
5000 次 Publish + 每条睡 20µs 的慢消费者
|
||||
实测 2.29ms,均摊 457 ns/token
|
||||
同步语义理论下限 100ms(5000 × 20µs)
|
||||
|
||||
订阅者 1 个:1.547ms(515 ns/次)
|
||||
订阅者 8 个:1.518ms(506 ns/次) ← 几乎不变,无线性恶化
|
||||
|
||||
环溢出(无消费者写 30000 次,cap=8192):均摊 35 ns/次 ← 仍 O(1)
|
||||
```
|
||||
|
||||
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token)——
|
||||
post-and-forget 在实现中成立。
|
||||
|
||||
最后一项的意义:消费者完全停摆时写端覆盖最旧 slot,这条路径仍是 O(1),
|
||||
故「消费者卡住」不会连带拖慢内核主循环。
|
||||
@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 子进程插件常驻开销实测(Part 6.3 验收项)。
|
||||
#
|
||||
# 对照基线:docs/zh/experiments/plugin-arch 实验 5 实测 17 子进程
|
||||
# PSS=12.9MB / RSS=29.1MB / 线程=84(原文档估计 50-70MB 偏高)。
|
||||
#
|
||||
# 用法:./measure-plugin-overhead.sh <homed-pid>
|
||||
set -uo pipefail
|
||||
|
||||
pid=${1:-}
|
||||
if [ -z "$pid" ]; then
|
||||
echo "用法: $0 <homed-pid>" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "/proc/$pid" ]; then
|
||||
echo "进程 $pid 不存在" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# homed 本体
|
||||
homed_rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status")
|
||||
homed_thr=$(awk '/^Threads:/ {print $2}' "/proc/$pid/status")
|
||||
|
||||
echo "=== homed 本体 ==="
|
||||
printf "RSS=%s kB 线程=%s\n" "$homed_rss" "$homed_thr"
|
||||
|
||||
# 插件子进程:homed 的直接子进程中执行 plugin.bin 的
|
||||
echo
|
||||
echo "=== 插件子进程 ==="
|
||||
total_rss=0
|
||||
total_pss=0
|
||||
total_thr=0
|
||||
count=0
|
||||
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
exe=$(readlink "/proc/$child/exe" 2>/dev/null || true)
|
||||
case "$exe" in
|
||||
*plugin.bin*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
thr=$(awk '/^Threads:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
|
||||
# RSS 与 PSS 统一从 smaps_rollup 读,保证口径一致。
|
||||
# 混用 status 的 VmRSS 与 smaps 的 Pss 会得出 PSS > RSS 的荒谬结果——
|
||||
# 两者对共享内存段(Pss_Shmem)的计入方式不同。
|
||||
rss=$(awk '/^Rss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
|
||||
pss=$(awk '/^Pss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
|
||||
if [ -z "$rss" ] || [ "$rss" = "0" ]; then
|
||||
rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
|
||||
fi
|
||||
binsz=$(stat -c%s "$(readlink "/proc/$child/exe" 2>/dev/null)" 2>/dev/null || echo 0)
|
||||
name=$(basename "$(readlink "/proc/$child/cwd" 2>/dev/null || echo unknown)")
|
||||
|
||||
printf " %-16s pid=%-8s RSS=%-8s PSS=%-8s 线程=%-3s 二进制=%s MB\n" \
|
||||
"$name" "$child" "$rss" "$pss" "$thr" \
|
||||
"$(awk -v b="$binsz" 'BEGIN{printf "%.1f", b/1048576}')"
|
||||
total_rss=$((total_rss + rss))
|
||||
total_pss=$((total_pss + pss))
|
||||
total_thr=$((total_thr + thr))
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 合计($count 个插件进程)==="
|
||||
awk -v rss="$total_rss" -v pss="$total_pss" -v thr="$total_thr" -v n="$count" '
|
||||
BEGIN {
|
||||
printf "RSS=%d kB (%.1f MB)\n", rss, rss/1024
|
||||
printf "PSS=%d kB (%.1f MB)\n", pss, pss/1024
|
||||
printf "线程=%d\n", thr
|
||||
if (n > 0) printf "均摊 RSS=%.2f MB PSS=%.2f MB 线程=%.1f\n", rss/1024/n, pss/1024/n, thr/n
|
||||
}'
|
||||
|
||||
echo
|
||||
echo "注:RSS/PSS 均取自 smaps_rollup,口径一致(PSS ≤ RSS)。"
|
||||
echo "PSS 低于 RSS 的部分即 Go runtime 只读代码页在进程间的共享收益。"
|
||||
|
||||
echo
|
||||
echo "对照实验 5 基线:17 进程 RSS=29.1MB PSS=12.9MB 线程=84"
|
||||
echo
|
||||
echo "⚠️ 该基线用的是 2.68MB 的最小插件;真实插件 3.3~15.2MB(browser 依赖最多)。"
|
||||
echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——"
|
||||
echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。"
|
||||
@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 批量重编外部插件为子进程模式(Part 6.3)。
|
||||
#
|
||||
# 用法:./rebuild-plugins.sh <插件名>...
|
||||
#
|
||||
# 关键性质:**不修改任何插件源码**。每个插件只需用新版 plugindev 重编,
|
||||
# plg.json 的 entry 仍写着 "plugin.so" 也无妨——工具链已不看这个字段。
|
||||
set -uo pipefail
|
||||
|
||||
PLUGINDEV=${PLUGINDEV:-/tmp/plugindev}
|
||||
EXAMPLE_DIR=${EXAMPLE_DIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)/third_party/homeagent-sdk/example"}
|
||||
export GOCACHE=${GOCACHE:-/tmp/gocache}
|
||||
export GOPATH=${GOPATH:-/tmp/gopath}
|
||||
|
||||
if [ ! -x "$PLUGINDEV" ]; then
|
||||
echo "plugindev 不存在或不可执行: $PLUGINDEV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ok=0
|
||||
fail=0
|
||||
failed_names=""
|
||||
|
||||
for name in "$@"; do
|
||||
dir="$EXAMPLE_DIR/$name"
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "✗ $name: 目录不存在"
|
||||
fail=$((fail + 1))
|
||||
failed_names="$failed_names $name"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 清理旧 C ABI 产物:同目录残留 .so 不影响构建,但会让人误以为还在用旧通道
|
||||
rm -rf "$dir/build" "$dir/dist"
|
||||
|
||||
out=$(cd "$dir" && "$PLUGINDEV" build 2>&1)
|
||||
rc=$?
|
||||
|
||||
# 判定成功的依据是产物存在,而非退出码:plugindev 对部分错误只打印不退出
|
||||
if [ $rc -eq 0 ] && ls "$dir"/build/plugin.bin* >/dev/null 2>&1; then
|
||||
n=$(ls "$dir"/build/plugin.bin* 2>/dev/null | wc -l)
|
||||
hmap=$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)
|
||||
printf "✓ %-14s %s 个平台产物 %s\n" "$name" "$n" "$(basename "${hmap:-无 hmap}")"
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
printf "✗ %-14s 构建失败\n" "$name"
|
||||
echo "$out" | tail -6 | sed 's/^/ /'
|
||||
fail=$((fail + 1))
|
||||
failed_names="$failed_names $name"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "成功 $ok / 失败 $fail"
|
||||
[ -n "$failed_names" ] && echo "失败:$failed_names"
|
||||
exit $([ $fail -eq 0 ] && echo 0 || echo 1)
|
||||
@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生产切换:经 pluginmgr 正规通道安装 17 个 hmap(Part 6.5)。
|
||||
|
||||
与手工拷贝方案的区别 —— 这里复用内核自己的安装逻辑:
|
||||
|
||||
validatePackage 校验 manifest + 平台二进制齐全
|
||||
StopAndUnload 停旧实例但**保留配置表**
|
||||
os.Rename 备份 解包失败自动回滚到旧版本
|
||||
platformBinary() 按 runtime 挑当前平台那份,重命名为 plugin.bin
|
||||
chmod 0755 补执行位
|
||||
|
||||
手工拷贝会重新实现这一套,且必然实现得更差(第一版就漏了 platforms 字段
|
||||
与配置保留语义)。
|
||||
|
||||
用法:
|
||||
switch-production.py 演练
|
||||
switch-production.py --apply 实际安装
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
PROD_PLUGINS = "/home/newqqagent/plugins"
|
||||
SDK_EXAMPLE = "/home/program/TrueAgent/third_party/homeagent-sdk/example"
|
||||
PLUGINMGR = "http://127.0.0.1:9876/plugins"
|
||||
|
||||
|
||||
def find_hmap(name):
|
||||
"""找插件的 hmap 包。
|
||||
|
||||
bundle:true -> <snake>_bundle.hmap(含多平台二进制)
|
||||
bundle:false -> <snake>_<goos>_<goarch>.hmap(qq 是这种)
|
||||
"""
|
||||
dist = os.path.join(SDK_EXAMPLE, name, "dist")
|
||||
if not os.path.isdir(dist):
|
||||
return None
|
||||
cands = [f for f in os.listdir(dist) if f.endswith(".hmap")]
|
||||
if not cands:
|
||||
return None
|
||||
for c in cands:
|
||||
if c.endswith("_bundle.hmap"):
|
||||
return os.path.join(dist, c)
|
||||
return os.path.join(dist, sorted(cands)[0])
|
||||
|
||||
|
||||
def install(path):
|
||||
"""POST 到 pluginmgr。overwrite=true 走原地更新分支,保留配置表。"""
|
||||
body = json.dumps({"path": path, "overwrite": True}).encode()
|
||||
req = urllib.request.Request(
|
||||
PLUGINMGR, data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
return json.loads(resp.read().decode()), None
|
||||
except urllib.error.HTTPError as e:
|
||||
return None, "HTTP %d: %s" % (e.code, e.read().decode()[:300])
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
|
||||
targets = sorted(
|
||||
d for d in os.listdir(PROD_PLUGINS)
|
||||
if os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.so"))
|
||||
or os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.bin"))
|
||||
)
|
||||
print("生产外部插件: %d 个" % len(targets))
|
||||
|
||||
# 先全部校验,任一缺包就整批中止。
|
||||
# 理由:新 homed 不认 .so,「一半装了一半没装」的中间态最难排查。
|
||||
plan = []
|
||||
missing = []
|
||||
for name in targets:
|
||||
h = find_hmap(name)
|
||||
if h is None:
|
||||
missing.append(name)
|
||||
else:
|
||||
plan.append((name, h))
|
||||
|
||||
if missing:
|
||||
print("\n✗ 中止:以下插件缺 hmap 包:")
|
||||
for m in missing:
|
||||
print(" " + m)
|
||||
print("\n先跑 rebuild-plugins.sh 重编。")
|
||||
return 1
|
||||
|
||||
print("✓ 全部 %d 个 hmap 就位\n" % len(plan))
|
||||
for name, h in plan:
|
||||
print(" %-16s %-44s %6d KB" % (
|
||||
name, os.path.basename(h), os.path.getsize(h) // 1024))
|
||||
|
||||
if not apply:
|
||||
print("\n[演练] 加 --apply 才实际安装")
|
||||
return 0
|
||||
|
||||
print("\n经 pluginmgr 安装(overwrite=true,保留配置)...")
|
||||
ok = 0
|
||||
failed = []
|
||||
for name, h in plan:
|
||||
result, err = install(h)
|
||||
if err:
|
||||
print(" ✗ %-16s %s" % (name, err))
|
||||
failed.append(name)
|
||||
continue
|
||||
if "error" in result:
|
||||
print(" ✗ %-16s %s: %s" % (
|
||||
name, result["error"], result.get("details", "")))
|
||||
failed.append(name)
|
||||
continue
|
||||
print(" ✓ %-16s %-12s v%s -> v%s config_kept=%s" % (
|
||||
name,
|
||||
result.get("action", "?"),
|
||||
result.get("previous_version", "?"),
|
||||
result.get("version", "?"),
|
||||
result.get("config_kept", False)))
|
||||
ok += 1
|
||||
|
||||
print("\n成功 %d / 失败 %d" % (ok, len(failed)))
|
||||
if failed:
|
||||
print("失败: " + " ".join(failed))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,111 +0,0 @@
|
||||
# 插件架构评估实验
|
||||
|
||||
[`../../架构迁移评估.md`](../../架构迁移评估.md) 中所有数字的来源。
|
||||
**18 项实验,一键复跑**,用于复核结论或在改动后验证回归。
|
||||
|
||||
```bash
|
||||
./run.sh # 跑全部(约 3-5 分钟)
|
||||
./run.sh 12 13 # 只跑指定实验
|
||||
./run.sh 1 1c # dlclose/NODELETE 组
|
||||
```
|
||||
|
||||
依赖:`go >= 1.21`、`gcc`、Linux(用到 `eventfd`/`memfd_create`/`dlopen`)。
|
||||
脚本在 `mktemp -d` 里构建,**不污染主仓 `go.mod`**;实验源码均带 `//go:build ignore`。
|
||||
|
||||
拉取 `golang.org/x/sys` 需要网络(实验 1/2/4/8/10)。本机走 clash:
|
||||
```bash
|
||||
export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
| 目录 | 主题 | 对应章节 |
|
||||
|---|---|---|
|
||||
| `01-dlclose-nodelete/` | `dlclose` 对 `DF_1_NODELETE` 是 no-op | 1.1 / 1.2 |
|
||||
| `02-feasibility/` | 新架构可行性 11 项 | 第七章 |
|
||||
| `03-lost-update/` | 副本模型的 lost update | 8.4 / 8.6 |
|
||||
| `04-cgo-uninterruptible/` | cgo 调用不可中断 | 9.3 |
|
||||
|
||||
## 实验清单与最近一次实测结果
|
||||
|
||||
复跑于 2026-08-31,go1.25.12 linux/amd64,192.168.2.60(12 核)。
|
||||
|
||||
### 01 组:dlclose / NODELETE
|
||||
|
||||
| # | 实验 | 结论 |
|
||||
|---|---|---|
|
||||
| 1a | Go 宿主经纯 C shim 加载/卸载第三层 `.so` | 纯 C 目标可卸载;Go c-shared 目标仍不可 |
|
||||
| 1b | `/proc/self/maps` 段数验证 | 纯 C: 5→**0**(真卸载);Go c-shared: 5→**5** |
|
||||
| 1c | 版本化路径 dlopen | handle 不同,`ver=v2` 生效(方案可行但泄漏,已否决) |
|
||||
|
||||
**关键**:`DF_1_NODELETE` 属于**被卸载对象自身**的 ELF 属性,
|
||||
与谁调用 `dlopen` 无关——套任何层数的 C 中间件都绕不过去。
|
||||
|
||||
### 02 组:新架构可行性
|
||||
|
||||
| # | 实验 | 最近结果 |
|
||||
|---|---|---|
|
||||
| 1 | eventfd 是否走 Go netpoller | 200 goroutine 阻塞 → 线程 **+0~1** ✅ |
|
||||
| 2 | 跨进程 eventfd + 偏移解引用 | 父子 mmap 基址不同,偏移仍正确;post **10.9 µs** |
|
||||
| 3 | 锁仲裁 RPC 往返成本 | **19.4 µs/次**(20000 次) |
|
||||
| 4 | post-and-forget vs 同步 Publish | 5.07s → 2.29ms(**2218x**) |
|
||||
| 5 | 17 子进程常驻开销 | **29.1MB RSS / 12.9MB PSS**,84 线程 |
|
||||
| 6 | 子进程崩溃隔离 | 退出码 **2**,EOF **2.5ms** 感知,宿主存活 |
|
||||
| 7 | 子进程热重载 | 同路径替换二进制 → v1→v2 立即生效 |
|
||||
| 8 | **跨进程并发改写 StageContext** | 5 进程 × 300 轮,**零丢失零撕裂** |
|
||||
| 9 | 持锁进程崩溃自愈 | 无死锁,**无需 robust mutex** |
|
||||
| 10 | 二进制零拷贝 | 100KB/1MB/5MB → **14-22x**,体积 −100% |
|
||||
| 11 | 工具调用 RPC 延迟 | p50 **19.6 µs**,占 LLM 往返 0.00065% |
|
||||
|
||||
### 03 组:副本模型缺陷
|
||||
|
||||
| # | 实验 | 最近结果 |
|
||||
|---|---|---|
|
||||
| 12 | 副本模型 lost update 率 | 内置 **0%** vs 外部 **35.8~36.8%** |
|
||||
| 13 | 现网 sanitizer+weather 冲突 | **1.6~4.3%** 清洗结果被覆盖 |
|
||||
|
||||
**实验 12 的对照设计是重点**:两组用**完全相同的并发扇出**
|
||||
(`stages.go:124` 的 `go func` + `wg.Wait()`),唯一差异是
|
||||
「共享同一 `*StageContext`」vs「快照-副本-写回」。
|
||||
|
||||
内置组 0% 证明**并发扇出这个原始设计是正确的**;
|
||||
副本组 36% 证明**跨 C ABI 边界后锁语义失效**才是缺陷所在。
|
||||
不要据此得出"应该取消并发"的结论。
|
||||
|
||||
⚠️ **13 的比率随机器负载波动**(观测区间 1.6%~4.3%)——它取决于两个插件
|
||||
handler 的实际执行耗时比。文档正文引用 1.6% 是首次测量值,
|
||||
**应理解为「量级在百分之几」而非精确常数**。
|
||||
|
||||
### 04 组:cgo 不可中断
|
||||
|
||||
| # | 实验 | 最近结果 |
|
||||
|---|---|---|
|
||||
| 14a | cgo 死循环 vs 子进程 Kill | cgo 泄漏;子进程 **零泄漏** |
|
||||
| 14b | 泄漏增长曲线(20 次) | 泄漏 **20 goroutine / 18 OS 线程**,线性 |
|
||||
|
||||
## 复跑时的注意事项
|
||||
|
||||
**结果会有波动,以下属正常**:
|
||||
|
||||
- 实验 12/13 的丢失率随调度波动(12 稳定在 35~37%,13 在 1.6~4.3%)
|
||||
- 实验 1 的线程增长为 0 或 1(取决于 netpoller 线程是否已存在)
|
||||
- 实验 10 的加速比 14~22x(受 CPU 缓存状态影响)
|
||||
- 实验 5 的 PSS 受同机其他 Go 进程影响(共享页计算)
|
||||
|
||||
**结果不应变的**(若变了说明环境或结论有问题):
|
||||
|
||||
- 实验 1b 中纯 C `.so` 的段数必须归 **0**,Go c-shared 必须**不归零**
|
||||
- 实验 8 的「总字符数 == 最终长度」必须成立(零丢失)
|
||||
- 实验 9 必须无死锁
|
||||
- 实验 12 的内置模型必须 **0%**
|
||||
- 实验 14b 的泄漏必须**线性增长**
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 实验 8 的 arena 未实现压实,64KB 用尽即停止写入(写入次数 < 5×300 属预期,
|
||||
见评估文档 3.3)
|
||||
- 实验 12/13 是**链路复刻**而非直接调用生产代码,
|
||||
证明的是「副本模型这一机制」存在缺陷,不能替代对 `sanitizer`/`weather`
|
||||
的真实行为回归测试
|
||||
- 实验 5 的插件是最小 stdio loop(2.68MB),真实插件(如 qq 7.5MB)开销更高
|
||||
- 无 Windows 环境,9.2 的 Windows DLL 缺陷**未经实测**,仅代码阅读
|
||||
@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 插件架构评估实验 —— 一键复跑
|
||||
# 用法: ./run.sh [实验编号...] 例: ./run.sh 12 13 留空跑全部
|
||||
# 依赖: go >= 1.21, gcc, Linux (eventfd/memfd/dlopen)
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ROOT=$(pwd)
|
||||
PASS=0; FAIL=0
|
||||
|
||||
need() { command -v "$1" >/dev/null || { echo "缺少依赖: $1"; exit 1; }; }
|
||||
need go; need gcc
|
||||
|
||||
# 统一的临时 module 环境(避免污染主仓 go.mod)
|
||||
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
banner() { echo; echo "════════ $* ════════"; }
|
||||
|
||||
# x/sys 只有 exp1/2/4/8/10 需要
|
||||
prep_xsys() {
|
||||
cat > "$1/go.mod" <<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 ]
|
||||
@ -541,9 +541,9 @@ v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 st
|
||||
- **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值
|
||||
等价于旧行为(L1)。
|
||||
- 合回 `main` 前需完成的发布动作:
|
||||
1. 同步更新 `docs/zh/plugin-interface-matrix.md`;
|
||||
2. 与 SDK 仓协同升 SDK 中版本;
|
||||
3. 遵守“只增不减、签名不改”边界。
|
||||
1. 与 SDK 仓协同升 SDK 中版本(`docs/git-branching.md` §七);
|
||||
2. 遵守“只增不减、签名不改”,并同步 hmapdev 模板接线
|
||||
(`docs/git-branching.md` §八)。
|
||||
- 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`)
|
||||
同步追加 `priority`,与公开 SDK 字段一一对应。
|
||||
|
||||
|
||||
@ -1,429 +0,0 @@
|
||||
# 外部插件接口不变矩阵(多进程化整改基线)
|
||||
|
||||
> 状态:**完成 v3**(2026-09-06)——v2 的迁移已上生产(内核 v1.0.0);v3 记录 v1.1.1 的公开接口**扩展**。
|
||||
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(hmapdev,当时名为 plugindev)**,外部插件业务代码零改动,只需用新工具链重编。
|
||||
>
|
||||
> **结果(已验证)**:`git diff third_party/homeagent-sdk/sdk/` 全程为空;17 个 `example/*/plugin.go` 逐字节未改
|
||||
> (`git status example/` 无输出);生产 17 插件全部经子进程通道运行。
|
||||
>
|
||||
> ⚠️ **v1.1.x 起冻结约束被有意解除**,因为「接口不变」这条约束本身是为**迁移期**设的:
|
||||
> 它要保的是「换运行模型不动业务代码」。迁移完成后,SDK 需要能随功能演进而扩展,
|
||||
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
|
||||
>
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
|
||||
> 必须同步更新本矩阵。
|
||||
>
|
||||
> 权威编号:plan.md 第 11 节(11.1~11.9)。本文档只做接口面盘点,不做实现。
|
||||
|
||||
---
|
||||
|
||||
## 一、迁移的形状(一句话)
|
||||
|
||||
```
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──hmapdev c-shared──> plugin.so
|
||||
homed ──dlopen──> plugin.so(C ABI bridge:51 个整数 method id)
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──hmapdev go build──> plugin.bin
|
||||
homed ──spawn──> plugin.bin(stdio JSON-RPC + shm + eventfd)
|
||||
```
|
||||
|
||||
**为什么接口可以不变**(已代码核实):
|
||||
|
||||
| 层 | 含 cgo? | 迁移后动作 |
|
||||
|---|---|---|
|
||||
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
|
||||
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go(只 import 公开 SDK) | **不动**(只重编) |
|
||||
| bridge 模板 `tools/hmapdev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| homed `internal/plugin/cabi/`(1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2) |
|
||||
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
|
||||
|
||||
---
|
||||
|
||||
## 二、合同面 A:公开 SDK 类型与接口(迁移前后必完全一致)
|
||||
|
||||
文件:`third_party/homeagent-sdk/sdk/{plugin.go,memory.go,knowledge.go,llm.go,settings.go}`
|
||||
|
||||
### A1. 插件入口契约(Plugin 接口)
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Start(sdk *PluginSDK) error
|
||||
Stop() error
|
||||
}
|
||||
// 外部插件实现 NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error)
|
||||
```
|
||||
|
||||
### A2. 插件可注册的 5 类组件(PluginSDK 方法)
|
||||
|
||||
| PluginSDK 方法 | 签名 | 外部插件使用量(example 实测) |
|
||||
|---|---|---|
|
||||
| `RegisterTool` | `(name string, def ToolDef, handler ToolHandler) error` | **86** |
|
||||
| `RegisterStage` | `(stage Stage, handler StageHandler, scope ...StageScope)` | 6 |
|
||||
| `RegisterOutputChannel` | `(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error` | 4 |
|
||||
| `RegisterInputChannel` | `(name string, def ChannelDef) error` | 2 |
|
||||
| `RegisterPluginAPI` | `(name string) error` | 0(定义存在,可用) |
|
||||
|
||||
### A3. 插件可调用的能力访问器(PluginSDK 方法)
|
||||
|
||||
| 访问器 | 返回 | 外部插件使用量 |
|
||||
|---|---|---|
|
||||
| `Settings()` | `SettingsAPI` | **17 插件全部使用**(Get/Set/List/GetCore/SetCore/ListCore/DataDir/GetPlugin/SetPlugin/ListPlugin/RegisterDef/Defs/Dump/Plugins) |
|
||||
| `Memory()` | `MemoryAPI`(Recall/Commit/Introspect/MergeEntities/Purge) | 低(controllable) |
|
||||
| `DocMemory()` | `DocMemoryAPI`(Query/Insert/**InsertWithMedia**/Remove/Stats) | 低(`InsertWithMedia` v1.1.0 新增) |
|
||||
| `TextMemory()` | `TextMemoryAPI`(Append) | 0 当前 |
|
||||
| `Knowledge()` | `KnowledgeAPI`(Search/Add/List) | 2 |
|
||||
| `LLM()` | `LLMAPI`(ListSources/SetSource/CurrentSource) | 0 当前 |
|
||||
| `Social()` | `SocialAPI`(**只读**:GetPerson/GetTrait/GetRelations/GetNetwork/ListPersons) | 0 当前 |
|
||||
| `Events()` | `EventSubscriber`(Subscribe) | 0 当前(**C ABI 空实现**,迁移后可获得) |
|
||||
| `PluginMgr()` | `PluginMgrAPI`(ReloadOne/ListLoadedPlugins/IsPluginDisabled) | 0 当前 |
|
||||
| `AutoRestart()` | `bool` | 配套 SetAutoRestart 用 |
|
||||
|
||||
### A4. 生命周期 / 工具注入(PluginSDK 方法)
|
||||
|
||||
| 方法 | 签名 | 备注 |
|
||||
|---|---|---|
|
||||
| `SetAutoRestart` / `AutoRestart` | `(bool)` / `() bool` | example 使用 16 次 |
|
||||
| `InjectText` | `(source, channel, text string)` | → C ABI case 5 |
|
||||
| `InjectInterruptText` | `(source, channel, text string)` | example 使用 6 次 → case 6 |
|
||||
| `InjectTextNoMemory` | `(source, channel, text string)` | → case 7 |
|
||||
| `InjectInputSync` | `(source, channel, text string) string` | → case 47(例:qq 闭环) |
|
||||
| `SetToolBlocks` | `(blocks []ContentBlock)` | ✅ **v1.1.1 已落地**(`io.setToolBlocks`);同版补上 `PluginSDK` 侧一直缺失的便捷包装——接口里有、便捷方法里没有,插件此前只能自己去拿 injector |
|
||||
| `InjectInputMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectMedia`。与 `SetToolBlocks` 的区别见下方说明 |
|
||||
| `InjectInputMediaSync` | `(source, channel, text string, blocks []ContentBlock) string` | **v1.1.0 新增** → `io.injectMediaSync` |
|
||||
| `InjectInterruptMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectInterruptMedia` |
|
||||
|
||||
**为何媒体注入不能搭 `SetToolBlocks` 的车**:后者只在**工具处理函数内部**可用,且媒体要等
|
||||
**下一条 tool message** 才到模型手上。插件主动发起一轮带媒体的对话、以及中断注入,
|
||||
需要各自的签名,且媒体在**本轮**就随消息发出,并自动落进 CAS、挂上媒体记忆引用。
|
||||
| `RegisterStopHandler` / `RunStopHandlers` | `(func())` / `()` | 已有(qq 等 1 次) |
|
||||
| `RegisterOnRemoveHandler` / `RunOnRemoveHandlers` | `(func())` / `()` | example 使用 3 次 |
|
||||
| `Set*`(SetIOInjector/SetMemoryAPI/.../SetPluginMgrAPI) | — | 供 bridge/核心启动时接线,插件不直接调 |
|
||||
|
||||
### A5. 核心数据类型(迁移前后结构体字段/JSON tag 不变)
|
||||
|
||||
| 类型 | 关键字段 | 备注 |
|
||||
|---|---|---|
|
||||
| `StageContext` | 16 字段:RawMessage/UserID/GroupID/ContextMsgs/LLMText/ReasoningContent/TokenUsage/ToolCalls/ToolResults/FinalText/Response/Phase/Memory/NoMemory/Extra/Errors + Lock/RLock/Unlock/RUnlock/IsResponded | **注意**:外部插件经 C ABI 只能看到 10 个字段(见 C3),迁移到共享内存后可看到全部 16 个 |
|
||||
| `ToolDef` | Name/Plugin/Description/Parameters/NoMemory/Cleaner(func) | `Cleaner` 是函数,**无法过 C ABI**(迁移后经 RPC/进程内保留) |
|
||||
| `ChannelDef` | NoMemory/Cleaner(func) | 同上 |
|
||||
| `ToolCall` / `ToolResult` / `MemItem` | ID/Name/Plugin/Arguments;CallID/Name/Plugin/Success/Result;Role/Content/Score | 全部纯 JSON 可序列化 |
|
||||
| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURL;URL/Detail;URL | 全部可偏移化(迁移评估 3.3 已核实) |
|
||||
| `MediaAttachment`(**v1.1.0 新增**) | Digest/MIME/Data/Name/Description | 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节去重),只给 `Digest` 是引用已有内容。**读路径不回 `Data`**——一次检索可能命中几十份媒体,全塞回去会撑爆跨进程消息 |
|
||||
| `Event` / `EventHandler` / `EventSubscriber` | Type/Source/Payload/Timestamp | 迁移后才对外部插件真正可用 |
|
||||
| `Triple` / `Entity` / `Relation` / `Doc` / `TextEvent` / `PersonProfile` / `SocialRelation` / `Knowledge` / `ConfigDef` | — | 全部 JSON 可序列化 |
|
||||
| `Triple`(**v1.1.0 扩展**) | += `SentenceText` / `MediaDigests` | 媒体引用挂在**句子**上(`SentenceText` → `sentences` → `sentence_id` → `media_refs`),所以 `MediaDigests` 非空而 `SentenceText` 为空时内核会用媒体标记本身充当句子 |
|
||||
| `Doc`(**v1.1.0 扩展**) | += `MediaDigests` / `Attachments` | `Query` 返回时由内核填充(仅元数据,不带字节) |
|
||||
| `TextEvent`(**v1.1.0 扩展**) | += `Attachments` | 写入时内核把标记并进正文;`RecentEvents` 读回时从标记反解 |
|
||||
|
||||
**函数类型字段盘点(唯一无法跨进程序列化的东西)**:
|
||||
- `ToolDef.Cleaner func(string) string`
|
||||
- `ChannelDef.Cleaner func(string) string`
|
||||
- `StageContext.mu sync.RWMutex`(~~锁~~ → 迁移后映射到跨进程锁仲裁)
|
||||
- 各种 `ToolHandler`/`StageHandler`/`EventHandler`/`func()`(回调 → RPC 反向注册)
|
||||
|
||||
→ 这些正是共享内存 + RPC 要保的「留在进程内的回调型资源」(迁移评估 3.5)。
|
||||
|
||||
---
|
||||
|
||||
## 三、合同面 B:bridge 51 个 method id ↔ SDK 方法映射(改造基线)
|
||||
|
||||
> ⏹️ **已完成(2026-09-03)**:整数 method id 已全部平移为 RPC method 名字符串,
|
||||
> 定义在 `internal/plugin/proc/protocol.go` 的 `Method*` 常量(共 60 个,含内核→插件方向)。
|
||||
> 原 `tmplLinuxBridge` 与 `meta.Core<Method>` 整数表**均已删除**。
|
||||
>
|
||||
> 两个遗留点:`case 25`(`CoreFreeString`)无对应 method(内存管理是 C 层特有问题);
|
||||
> `io.setToolBlocks` 已定义但内核侧仍返回未实现(C ABI 时代也是空实现,非回归)。
|
||||
>
|
||||
> 下表保留作为历史对照。
|
||||
|
||||
| # | method id(今天 C ABI) | SDK 背的方法 | 迁移后 RPC method 名(建议) |
|
||||
|---|---|---|---|
|
||||
| 1 | CORE_REGISTER_TOOL | RegisterTool | `tool.register` |
|
||||
| 2 | CORE_REGISTER_STAGE | RegisterStage | `stage.register` |
|
||||
| 3 | CORE_REGISTER_OUTPUT_CH | RegisterOutputChannel | `output.register` |
|
||||
| 4 | CORE_REGISTER_PLUGIN_API | RegisterPluginAPI | `api.register` |
|
||||
| 5 | CORE_INJECT_TEXT | InjectText | `io.injectText` |
|
||||
| 6 | CORE_INJECT_INTERRUPT_TEXT | InjectInterruptText | `io.injectInterrupt` |
|
||||
| 7 | CORE_INJECT_TEXT_NO_MEMORY | InjectTextNoMemory | `io.injectTextNoMem` |
|
||||
| 47 | CORE_INJECT_INPUT_SYNC | InjectInputSync | `io.injectInputSync` |
|
||||
| 8 | CORE_SET_AUTO_RESTART | SetAutoRestart | `lifecycle.autoRestart` |
|
||||
| 9 | CORE_MEMORY_RECALL | Memory().Recall | `memory.recall` |
|
||||
| 10 | CORE_MEMORY_COMMIT | Memory().Commit | `memory.commit` |
|
||||
| 11 | CORE_MEMORY_INTROSPECT | Memory().Introspect | `memory.introspect` |
|
||||
| 12 | CORE_MEMORY_MERGE | Memory().MergeEntities | `memory.merge` |
|
||||
| 13 | CORE_MEMORY_PURGE | Memory().Purge | `memory.purge` |
|
||||
| 14 | CORE_DOC_QUERY | DocMemory().Query | `doc.query` |
|
||||
| 15 | CORE_KNOWLEDGE_SEARCH | Knowledge().Search | `knowledge.search` |
|
||||
| 16 | CORE_SETTINGS_GET | Settings().Get | `settings.get` |
|
||||
| 17 | CORE_SETTINGS_SET | Settings().Set | `settings.set` |
|
||||
| 18 | CORE_SETTINGS_REGISTER_DEF | Settings().RegisterDef | `settings.registerDef` |
|
||||
| 19 | CORE_LLM_LIST_SOURCES | LLM().ListSources | `llm.listSources` |
|
||||
| 20 | CORE_LLM_SET_SOURCE | LLM().SetSource | `llm.setSource` |
|
||||
| 21 | CORE_SOCIAL_GET_PERSON | Social().GetPerson | `social.getPerson` |
|
||||
| 22 | CORE_SOCIAL_GET_NETWORK | Social().GetNetwork | `social.getNetwork` |
|
||||
| 23 | CORE_SUBSCRIBE | Events().Subscribe | `events.subscribe`(**今天空实现**) |
|
||||
| 24 | CORE_UNSUBSCRIBE | (退订闭包) | `events.unsubscribe`(**今天空实现**) |
|
||||
| 25 | CORE_FREE_STRING | (内存释放) | 删除(RPC 无此概念) |
|
||||
| 26 | CORE_SETTINGS_GET_CORE | Settings().GetCore | `settings.getCore` |
|
||||
| 27 | CORE_SETTINGS_SET_CORE | Settings().SetCore | `settings.setCore` |
|
||||
| 28 | CORE_SETTINGS_LIST_CORE | Settings().ListCore | `settings.listCore` |
|
||||
| 29 | CORE_SETTINGS_GET_PLUGIN | Settings().GetPlugin | `settings.getPlugin` |
|
||||
| 30 | CORE_SETTINGS_SET_PLUGIN | Settings().SetPlugin | `settings.setPlugin` |
|
||||
| 31 | CORE_SETTINGS_LIST_PLUGIN | Settings().ListPlugin | `settings.listPlugin` |
|
||||
| 32 | CORE_DOC_INSERT | DocMemory().Insert | `doc.insert` |
|
||||
| 33 | CORE_DOC_REMOVE | DocMemory().Remove | `doc.remove` |
|
||||
| 34 | CORE_DOC_STATS | DocMemory().Stats | `doc.stats` |
|
||||
| 35 | CORE_KNOWLEDGE_ADD | Knowledge().Add | `knowledge.add` |
|
||||
| 36 | CORE_KNOWLEDGE_LIST | Knowledge().List | `knowledge.list` |
|
||||
| 37 | CORE_LLM_CURRENT_SOURCE | LLM().CurrentSource | `llm.currentSource` |
|
||||
| 38 | CORE_SOCIAL_GET_TRAIT | Social().GetTrait | `social.getTrait` |
|
||||
| 39 | CORE_SOCIAL_GET_RELATIONS | Social().GetRelations | `social.getRelations` |
|
||||
| 40 | CORE_SOCIAL_LIST_PERSONS | Social().ListPersons | `social.listPersons` |
|
||||
| 41 | CORE_TEXT_MEMORY_APPEND | TextMemory().Append | `textmemory.append` |
|
||||
| 42 | CORE_SETTINGS_LIST | Settings().List | `settings.list` |
|
||||
| 43 | CORE_SETTINGS_DEFS | Settings().Defs | `settings.defs` |
|
||||
| 44 | CORE_SETTINGS_DUMP | Settings().Dump | `settings.dump` |
|
||||
| 45 | CORE_SETTINGS_PLUGINS | Settings().Plugins | `settings.plugins` |
|
||||
| 51 | CORE_SETTINGS_DATA_DIR | Settings().DataDir | `settings.dataDir` |
|
||||
| 46 | CORE_REGISTER_INPUT_CH | RegisterInputChannel | `input.register` |
|
||||
| 48 | CORE_PLUGIN_RELOAD_ONE | PluginMgr().ReloadOne | `plugin.reloadOne` |
|
||||
| 49 | CORE_PLUGIN_LIST_LOADED | PluginMgr().ListLoadedPlugins | `plugin.listLoaded` |
|
||||
| 50 | CORE_PLUGIN_IS_DISABLED | PluginMgr().IsPluginDisabled | `plugin.isDisabled` |
|
||||
|
||||
**bridge 侧反向调用(内核 → 插件,RPC 的另一半)**:
|
||||
|
||||
| 今天 | 迁移后 |
|
||||
|---|---|
|
||||
| `go_invoke_tool(name, argsJSON)` | `tool.invoke`(homed → pinvoke) |
|
||||
| `go_invoke_stage(stage, ctxJSON, resultOut)` | `stage.invoke`(homed → pinvoke,共享内存数据面) |
|
||||
| `go_invoke_output(channel, type, payloadJSON)` | `output.invoke`(homed → pinvoke) |
|
||||
| `go_free_string` | 删除 |
|
||||
|
||||
---
|
||||
|
||||
## 四、合同面 C:StageContext 跨 ABI 现状 → 共享内存目标
|
||||
|
||||
> ✅ **已达成(2026-09-03)**:子进程插件现在看到全部 18 个字段(枚举见
|
||||
> `internal/plugin/proc/shm.go`),且可写回。生产实测:sanitizer 在另一个进程里
|
||||
> 改写 13590 字节文本,内核读到改写结果(`stage post_action 改写了 1 个字段`)。
|
||||
|
||||
### C1. 迁移前(C ABI 副本模型):插件只看到 10 个字段
|
||||
|
||||
`stageContextWritable`(templates.go:762)下发/回传的字段:
|
||||
|
||||
```
|
||||
raw_message user_id group_id phase llm_text final_text no_memory
|
||||
+ response(可选) + tool_calls(有才传) + tool_results(有才传)
|
||||
```
|
||||
|
||||
**看不到的 6 个字段**:`ContextMsgs` / `ReasoningContent` / `TokenUsage` / `Memory` / `Extra` / `Errors`
|
||||
|
||||
### C2. 迁移后(共享内存 + 锁仲裁):插件可看到/改写全部字段 — ✅ 已实现
|
||||
|
||||
字段级 `Slice{Off,Len}` 描述符 + 内核仲裁锁。插件进程内保留原生 `StageContext`,
|
||||
handler 照常读写,`Lock/RLock` 映射到跨进程锁仲裁 RPC(`stage.lock`/`stage.unlock`),
|
||||
handler 返回时脏字段写回共享段。
|
||||
|
||||
**关键设计决定**:全部子进程插件共享**同一块 memfd**。第一版设计是每插件一段,
|
||||
那会退化成副本模型,复现 §8.4 的 35.8~36.8% lost update。
|
||||
|
||||
→ **接口形式不变,能力变强**(能力断层消除:外部插件拿回 ContextMsgs 等)。
|
||||
|
||||
Windows 同步受益:从「只下发 3 字段、无写回」升到全字段可见 + 写回,
|
||||
与 Unix 共用同一套 RPC 实现与共享段布局。
|
||||
|
||||
### C3. lost update 的合同面定义 — ✅ 已消除
|
||||
|
||||
C ABI 时代 `stageContextWritable` **无条件回传 10 个字段的当前快照**——两个插件
|
||||
(sanitizer 改 ToolResults + weather 只读)并行时,weather 的回传会覆盖 sanitizer
|
||||
的清洗结果(实测 1.6~4.3%,高并发下 35.8~36.8%)。
|
||||
|
||||
Part 0.2 先做了过渡补丁(只回传真正变更的字段);Part 4 的共享内存模型从根上解决
|
||||
(字段级描述符 + 锁仲裁,并发改写同一对象)。
|
||||
|
||||
回归基线:`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`、
|
||||
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`。
|
||||
|
||||
---
|
||||
|
||||
## 五、外部插件实际触达面(example 18 插件实测汇总)
|
||||
|
||||
> 这是「17 个存量插件业务代码零改动」的直接依据——它们**只用**下表这些 API,全部在公开 SDK 合同面内。
|
||||
|
||||
| 插件 | 用到的 SDK 触达 |
|
||||
|---|---|
|
||||
| qq(最复杂) | SetAutoRestart / RegisterDef×11 / RegisterOutputChannel(qq, 4 caps) / RegisterInputChannel(qq, NoMemory+Cleaner) / RegisterStage(BeforeToolcall, OwnTools) / RegisterTool×N / InjectInterruptText×2 / getSetting(p.sdk.Settings()) |
|
||||
| weather / rss / bili / ocr / files / memo / music / a2a / acp / ai_image / browser / calendar / editdoc / recoverydiag / sanitizer / vanblog / luademo | RegisterTool / Settings / SetAutoRestart / (部分) RegisterStage / RegisterOutputChannel / InjectInputSync / Knowledge / RegisterStopHandler / RegisterOnRemoveHandler |
|
||||
|
||||
**结论**:外部插件触达面 ⊆ 公开 SDK 合同面;无任何插件直接使用方法 id 或 bridge 内部符号。
|
||||
→ 只要公开 SDK 签名不变 + bridge 语义平移,接口不变约束成立。
|
||||
|
||||
---
|
||||
|
||||
## 六、迁移后外部插件「新获得」的能力(合同面扩展——只增不减)
|
||||
|
||||
| 能力 | 迁移前 | 迁移后 | 实际结果 |
|
||||
|---|---|---|---|
|
||||
| 事件订阅 `Events().Subscribe`(case 23/24) | ❌ 空实现 | ✅ 事件环(EvtRing + eventfd + 独立游标) | ✅ 已接线(当前零用户) |
|
||||
| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ `io.setToolBlocks` | ✅ **v1.1.1 已落地**(走 JSON 而非共享段二进制通道,理由见 §九) |
|
||||
| 媒体入记忆(`InsertWithMedia`、`Triple.MediaDigests`) | ❌ 不存在 | ✅ CAS + 引用计数 GC | ✅ **v1.1.0 类型 / v1.1.1 内核实现** |
|
||||
| 插件主动发起带媒体的一轮对话(`InjectInputMedia*`) | ❌ 不存在 | ✅ 媒体在本轮就到模型手上 | ✅ **v1.1.1** |
|
||||
| `ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` | ❌ 看不到 | ✅ 共享内存全字段 | ✅ 18 字段全可见可写 |
|
||||
| 插件崩溃隔离 | ❌ panic 带崩 homed | ✅ 子进程独立崩溃 | ✅ 测试 + 生产验证 |
|
||||
| 热重载 `.so` | ❌ `DF_1_NODELETE` no-op | ✅ 同路径替换 `.bin` 即生效 | ✅ 生产实测 |
|
||||
| 工具超时取消 | ❌ cgo 不可中断(泄漏线程) | ✅ `Process.Kill()` 真取消 | ✅ 整套新架构零 cgo |
|
||||
| `output_send` 结果 | ❌ 永远假成功 | ✅ 可同步等真实结果 | ✅ 生产实测 `map[status:sent]` |
|
||||
| Lua/Windows DLL 路径 | ❌ 三套 ABI 分裂 | ✅ 收敛为单一 RPC 实现 | ⚠️ Windows 已收敛;Lua 仍独立(留待后续) |
|
||||
|
||||
**三项未完全兼得的说明**:
|
||||
|
||||
- `SetToolBlocks`:`io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`,但内核侧 handler
|
||||
仍返回未实现。C ABI 时代它也是空实现(§1.4),故**不是回归**,但也没兑现承诺。
|
||||
- Lua:`lua_plugin.go`/`dynamic_lua.go` 仍走自己的路径。Lua 经解释器不经 C ABI,
|
||||
不属于本轮要消除的 6 类缺陷,因此不阻塞。收敛第三套 ABI 是独立优化。
|
||||
- 事件订阅:机制已完成(内核侧 `EvtRing` + 模板侧 `evtConsumerLoop`),
|
||||
但**无任何现有插件使用 `Events().Subscribe`**,所以生产上未经真实负载检验。
|
||||
|
||||
**刻意不给**(权限梯度显式化,非技术限制):`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/
|
||||
`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`
|
||||
(内核内部机制)。清单与理由记在 `internal/plugin/proc/capability.go` 的
|
||||
`withheldCapabilities`,`TestCapability_WithheldListIsDocumented` 守护。
|
||||
|
||||
这一项从「C ABI 表达能力的意外产物」变成**显式策略**:以前拿不到是因为
|
||||
C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 case 就能捅穿);
|
||||
现在是三道闸:类型层(`procCore` 命名字段不嵌入)+ 能力集(manifest 声明)
|
||||
+ RPC 边界(返回明确错误而非静默忽略)。
|
||||
|
||||
---
|
||||
|
||||
## 七、接口冻结检查点(全部已通过)
|
||||
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
验收:weather 业务代码逐字节未改(`git status example/` 无输出)。
|
||||
2. ✅ **阶段 3(共享内存)**:子进程并发改写 StageContext 丢失率 = 0%
|
||||
(`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` 与
|
||||
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`)。
|
||||
3. ✅ **阶段 5**:17 个外部插件全部 `.bin` 化、cabi 删除(-3198 行);
|
||||
`go build ./...` 与全仓 `go test ./...` 均通过。
|
||||
4. ✅ **全程**:`git diff third_party/homeagent-sdk/sdk/` 为零——接口冻结的硬证据。
|
||||
5. ⚠️ **v1.1.x 起该检查项不再适用**:冻结是迁移期的约束,迁移完成即到期(见 §九)。
|
||||
取代它的门禁是「存量插件零改动零重编」——见 §九的验证方式。
|
||||
|
||||
生产端到端(2026-09-03,真实 QQ 消息):
|
||||
|
||||
```
|
||||
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
|
||||
output_send__qq output_send__qq qq_mark_read])
|
||||
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
|
||||
[proc] sanitizer stage post_action 改写了 1 个字段
|
||||
tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、v1.1.x 的接口扩展规则(冻结解除后的替代约束)
|
||||
|
||||
冻结约束是为**迁移期**设的:它要保的是「换运行模型不动业务代码」。迁移完成后继续冻结,
|
||||
等于让 SDK 永远停在迁移那天的能力面——多模态这类功能永远到不了插件手上。
|
||||
|
||||
取代它的是三条更弱但仍然硬的约束:
|
||||
|
||||
### 1. 只增不减,签名不改
|
||||
|
||||
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
|
||||
|
||||
实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
|
||||
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
|
||||
而那些插件根本不关心媒体。
|
||||
|
||||
### 2. 新增方法必须是「插件调用、内核实现」方向
|
||||
|
||||
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增三个方法后,插件只是
|
||||
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
|
||||
每个存量插件都会因未实现而编译失败。
|
||||
|
||||
因此 `SDKCompatibleVersion` 与 SDK 的 `CoreVersion` 都不必随之跃迁:
|
||||
1.1.0 的 SDK 配 1.0.0 编的插件仍然成立。
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
|
||||
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
|
||||
E2E 用例编译失败),不是靠人工检查发现的。
|
||||
|
||||
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
|
||||
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
|
||||
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
|
||||
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,所以漂移不会被编译器抓到
|
||||
(v1.1.1 修的时候发现它的 `Triple` 用的是 `Predicate`,而公开 SDK 一直叫 `Relation`)。
|
||||
|
||||
### 验证方式(取代「diff 为零」)
|
||||
|
||||
| 检查 | 命令 | v1.1.1 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`(17 个) | ✅ 17/17 通过 |
|
||||
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin` 跑 `TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
|
||||
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE(13 例压测) |
|
||||
|
||||
### v1.2.x 的接口扩展(2026-09-12)
|
||||
|
||||
1.2.0 把「记不记入记忆 / 要不要据此裁剪上下文」从**只有工具与通道能声明**,扩到**注入侧也能声明**:
|
||||
|
||||
| 新增 | 方向 | 说明 |
|
||||
|---|---|---|
|
||||
| `InjectOptions{NoMemory, ContextPolicy, CleanerName}` | 新增类型 | 单次注入的行为声明 |
|
||||
| `ContextPolicyNone` / `ContextPolicyPrune` + `ValidContextPolicy` | 新增常量/函数 | 取值只有 `""` / `none` / `prune`;`prune` 必须显式声明 |
|
||||
| 六个 `*Opts` 变体(Text / InterruptText / InputSync / InputMedia / InputMediaSync / InterruptMedia) | 插件调用、内核实现 | 旧的三参数方法保留为**零值糖**,与 `InjectOptions{}` 逐键等价 |
|
||||
| `ChannelDef.ContextPolicy` + `ChannelDef` 的 JSON tag | 结构体字段 | 通道也可声明裁剪;补 tag 是因为通道定义要跨进程传给内核,而 `Cleaner` 是函数必须忽略——无 tag 时新增字段会被**静默丢掉** |
|
||||
|
||||
签名层面零变更(六个方法全是新增),满足第 1、2 条。
|
||||
|
||||
**但「接口纯追加」不等于「无需重编」**:1.2.0 同时把插件运行协议升到 2
|
||||
(fd3 布局改变,不支持滚动升级),`ProtocolVersion` 不匹配会在握手时被明确拒绝
|
||||
并提示用配套 plugindev 重编。两件事必须分开说,否则会被误读成「既然纯追加就还能用旧产物」。
|
||||
|
||||
#### 这次扩展自己抓出来的两处漂移(都是本节第 3 条要防的那类)
|
||||
|
||||
1. **模板接线守卫红了**:`TestProcTemplate_CoversAllCoreMethods` 要求模板出现内核提供的
|
||||
每一个 method id,而注入标志位落地后模板不再发 `io.injectTextNoMem`(旧模板发它,
|
||||
现在走 `io.injectText` + `NoMemory` 标志位)。内核保留该 id 是**刻意的向后兼容面**
|
||||
(用那时模板编出的二进制仍在外面),不是漏接线——所以改的是判据:把它移入显式的
|
||||
`deprecated` 表,并加**反向保护**(条目一旦重新出现在模板里就报错,避免这张表
|
||||
退化成「永久豁免」的垃圾抽屉)。
|
||||
2. **mocksdk 缺一个方法**:拿公共 SDK `IOInjector` 的 14 个方法名与 mock 的方法集
|
||||
**机械求差**,差集恰好是旧的三参数 `InjectInputSync`——通道类插件(qq / a2a)完成
|
||||
「入站 → agent 处理 → 回复取回」闭环要调的那个。`git log -S` 证实它**从来就缺**,
|
||||
不是本次引入;补齐后差集为空。(上次漂的是 `Triple.Predicate` vs `Relation`,同一类问题。)
|
||||
|
||||
#### 验证(1.2.0,本机实测)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | 逐个 `cd example/<n> && go vet ./...` | ✅ 17/17 通过(`luademo` 是 Lua、无 `go.mod`,跳过) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ 全绿(修复前为红;反向保护另用「把 id 塞回模板」验证过会报错) |
|
||||
| 并发安全 | `go test -race -count=5 ./sdk/` | ✅ ok |
|
||||
| mocksdk 未漂移 | 方法集求差(14 个方法) | ✅ 差集为空 |
|
||||
|
||||
### 为何媒体块走 JSON 而不是共享段二进制通道
|
||||
|
||||
`SetToolBlocks` 的原设计是「二进制落 arena,Slice 描述符回传」。实际落地时改走 JSON:
|
||||
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要让这四个 method 跟其余
|
||||
51 个分道扬镳。共享段的价值在于**并发改写同一份状态**(StageContext 的 lost update),
|
||||
而媒体块是单向传递的不可变数据,没有这个问题。
|
||||
|
||||
---
|
||||
|
||||
## 八、关联文档
|
||||
|
||||
- `docs/zh/架构迁移评估.md` — 完整论证(§3.2 method id 平移、§3.3 数据面、§3.4 SDK 封装、§3.5 回调型资源、§3.8 能力对齐)
|
||||
- `plan.md` §11 — 11.1~11.9 修复清单(唯一权威编号)
|
||||
- `third_party/homeagent-sdk/sdk/` — 合同面 A 的代码实现(全程零 diff)
|
||||
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
|
||||
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
|
||||
- `internal/plugin/proc/capability.go` — 权限梯度(capability 组 + `withheldCapabilities`)
|
||||
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
|
||||
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具
|
||||
@ -677,4 +677,4 @@ go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal
|
||||
|
||||
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
|
||||
- 若需要动公开 SDK(例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
|
||||
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。
|
||||
追加,并按 `docs/git-branching.md` §八 的接线清单同步(含 hmapdev 模板)与 SDK 仓版本(§七)。
|
||||
|
||||
1621
docs/zh/架构迁移评估.md
1621
docs/zh/架构迁移评估.md
File diff suppressed because it is too large
Load Diff
@ -40,8 +40,8 @@ func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
// 子进程插件加载(plugin.bin)——外部插件多进程化的加载入口。
|
||||
//
|
||||
// 设计依据:docs/zh/架构迁移评估.md §3(stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面)
|
||||
// 实施记录见 docs/zh/架构迁移评估.md(子进程化论证与实验数据)
|
||||
// 三个通信面:stdio JSON-RPC(控制)+ 共享内存段(数据)+ eventfd 通知环。
|
||||
// 设计取舍见 assets/docs/zh/ARCHITECTURE.md「子进程插件的三个通信面」。
|
||||
|
||||
// validateProcBinary 校验 plugin.bin 是否存在且可执行。
|
||||
// 返回 ("", nil) 表示该目录不是 proc 插件。
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// entry 分派(见 docs/zh/架构迁移评估.md 的入口双通道章节)。
|
||||
// entry 分派:按 manifest 的 entry 把插件分派到 proc / lua / skill 三条通道。
|
||||
//
|
||||
// C ABI 通道(.so/.dll/.dylib)已整体退场:外部插件统一走子进程 + stdio RPC。
|
||||
// 这些测试守住的是「旧产物给明确错误」而非「静默跳过」——后者会让
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
|
||||
// 子进程架构的性能基准(Part 6.6 验收项)。
|
||||
//
|
||||
// 对照基线来自 docs/zh/experiments/plugin-arch:
|
||||
// 对照基线(迁移期可行性实验实测,那些实验脚本已随迁移完成删除):
|
||||
//
|
||||
// 实验 3 锁仲裁 RPC 往返 19.40 µs/次
|
||||
// 实验 4 post-and-forget 5.07s → 2.29ms(5000 token + 20µs 慢消费者)
|
||||
|
||||
@ -19,7 +19,7 @@ import (
|
||||
|
||||
// Process 管理一个外部插件子进程:spawn / 双向 JSON-RPC / 优雅停止 / 崩溃检测。
|
||||
//
|
||||
// 设计依据:docs/zh/架构迁移评估.md §4.1 阶段 2、§2.3(保留现有生命周期机制)
|
||||
// 生命周期:沿用内核既有的注册面摘除 + 退避重启机制(见 stats/registry)。
|
||||
//
|
||||
// 与 C ABI 路径的关键差异:
|
||||
// - **崩溃隔离**:插件 panic 只让子进程退出,homed 存活(今日 panic 跨 C 栈可带崩内核)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// Package proc 实现外部插件的子进程加载通道(plugin.bin)。
|
||||
//
|
||||
// 设计依据:docs/zh/架构迁移评估.md 第三章
|
||||
// 数据面设计:定长头 + arena,变长数据用相对基址的 {off,len} 描述符
|
||||
//(相对偏移是关键:各进程 mmap 到不同虚拟地址也能正确解引用)。
|
||||
//
|
||||
// homed ──spawn──> plugin(纯 Go 二进制,无 cgo)
|
||||
// ├── stdio JSON-RPC 控制面:51 个 method id 平移为 method 名(§3.2)
|
||||
|
||||
14
plan.md
14
plan.md
@ -607,12 +607,12 @@ CLI 本机自执行命令已有 cmd 插件,不做反向操控 CLI。
|
||||
|
||||
## 11. 插件架构缺陷修复 + 子进程化迁移评估
|
||||
|
||||
> 完整评估文档:[`docs/zh/架构迁移评估.md`](docs/zh/架构迁移评估.md)
|
||||
> —— **先读其第零章「给接手者的阅读指引」**,该文档是增量写成的,前六章部分结论已被后续推翻。
|
||||
> 可复跑实验:[`docs/zh/experiments/plugin-arch/`](docs/zh/experiments/plugin-arch/)(18 项,`./run.sh`)
|
||||
> 迁移**已于 v1.0.0 完成并上生产**。评估稿、可行性实验(18 项)与迁移基线矩阵
|
||||
> 均为迁移期临时文档,已随迁移完成一并删除(见 git 历史)。
|
||||
> 现状架构见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md) 的「插件系统」;
|
||||
> 当前运行态的事实以源码为准(`internal/plugin/`)。
|
||||
>
|
||||
> **本节 11.1~11.6 是修复项的唯一权威编号。** 评估文档中出现的 `0.x` / `A-F`
|
||||
> 仅为历史分组,勿用于实施。本节只列可执行项与决策状态;论证与数据见评估文档。
|
||||
> **本节 11.1~11.6 保留为历史工单**,各条勾选状态即当时的完成情况。
|
||||
|
||||
### 11.0-pre 三个易被误解的前提(动手前必读)
|
||||
|
||||
@ -933,8 +933,8 @@ context 累积导致的内存增长。
|
||||
> 三项合入门禁**已全部通过**:`make test` 零失败、`go vet ./...` 无告警、
|
||||
> `git diff main -- third_party/homeagent-sdk/sdk/` 为空(接口冻结不变量)。
|
||||
>
|
||||
> 迁移的执行记录(Part 0~6 过程稿)已随迁移完成而删除;论证与实验数据仍见
|
||||
> `docs/zh/架构迁移评估.md`。
|
||||
> 迁移的执行记录、评估稿与可行性实验均已随迁移完成而删除(见 git 历史);
|
||||
> 现状以 `assets/docs/zh/ARCHITECTURE.md` 与源码为准。
|
||||
|
||||
### 12.1 ✅ 已完成:合并到 main + 发布分支(2026-09-03 ~ 09-06)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user