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:
JianFeeeee
2026-09-19 19:22:48 +08:00
parent 7213edd181
commit c0274b71d5
47 changed files with 76 additions and 4094 deletions

View File

@ -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/`)。

View File

@ -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 shimshim 本身常驻,无所谓)
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(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
}
}

View File

@ -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✅ 卸载成功")
}
}

View File

@ -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)
}

View File

@ -1,2 +0,0 @@
#include <stdio.h>
const char* probe_version(void){ return "V1"; }

View File

@ -1,2 +0,0 @@
#include <stdio.h>
const char* probe_version(void){ return "V2"; }

View File

@ -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); }

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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() }
}

View File

@ -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("=== 实验 1eventfd 是否走 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())
}

View File

@ -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")
}

View File

@ -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]))
}
}

View File

@ -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))
}

View File

@ -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()
}

View File

@ -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 使流式发布与消费者速度解耦")
}
}

View File

@ -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("=== 实验 517 个 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() }
}

View File

@ -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()
}
}

View File

@ -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("=== 实验 5b17 个 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() }
}

View File

@ -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")
}

View File

@ -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()
}
}

View File

@ -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")
}
}

View File

@ -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")
}

View File

@ -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))
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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 }
// 插件 handlerctx.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 非原子:快照与写回之间的窗口导致覆盖")
}

View File

@ -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_querysanitizer+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")
}
}

View File

@ -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 回收全部资源")
}

View File

@ -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 独占一个 MOS 线程),无法被抢占或回收")
}

View File

@ -1,2 +0,0 @@
#include <unistd.h>
void hang_forever(void) { while(1) sleep(1); }

View File

@ -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-0215 个真实插件)
```
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.8MBbrowser 依赖最多)。
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 而非 failCI 不强制先跑重编脚本。
## 压测与延迟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-02AMD 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µs2.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
同步语义理论下限 100ms5000 × 20µs
订阅者 1 个1.547ms515 ns/次)
订阅者 8 个1.518ms506 ns/次) ← 几乎不变,无线性恶化
环溢出(无消费者写 30000 次cap=8192均摊 35 ns/次 ← 仍 O(1)
```
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token——
post-and-forget 在实现中成立。
最后一项的意义:消费者完全停摆时写端覆盖最旧 slot这条路径仍是 O(1)
故「消费者卡住」不会连带拖慢内核主循环。

View File

@ -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.2MBbrowser 依赖最多)。"
echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——"
echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。"

View File

@ -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)

View File

@ -1,132 +0,0 @@
#!/usr/bin/env python3
"""生产切换:经 pluginmgr 正规通道安装 17 个 hmapPart 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>.hmapqq 是这种)
"""
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())

View File

@ -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-31go1.25.12 linux/amd64192.168.2.6012 核)。
### 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 loop2.68MB真实插件 qq 7.5MB开销更高
- Windows 环境9.2 Windows DLL 缺陷**未经实测**仅代码阅读

View File

@ -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 ]

View File

@ -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 字段一一对应。

View File

@ -1,429 +0,0 @@
# 外部插件接口不变矩阵(多进程化整改基线)
> 状态:**完成 v3**2026-09-06——v2 的迁移已上生产(内核 v1.0.0v3 记录 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.soC ABI bridge51 个整数 method id
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──hmapdev go build──> plugin.bin
homed ──spawn──> plugin.binstdio 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 47qq 闭环) |
| `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/ArgumentsCallID/Name/Plugin/Success/ResultRole/Content/Score | 全部纯 JSON 可序列化 |
| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURLURL/DetailURL | 全部可偏移化(迁移评估 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)。
---
## 三、合同面 Bbridge 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` | 删除 |
---
## 四、合同面 CStageContext 跨 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 RACE13 例压测) |
### 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` 的原设计是「二进制落 arenaSlice 描述符回传」。实际落地时改走 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/` 迁移执行期工具

View File

@ -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 仓版本(§七)

File diff suppressed because it is too large Load Diff

View File

@ -40,8 +40,8 @@ func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, e
// 子进程插件加载plugin.bin——外部插件多进程化的加载入口。
//
// 设计依据docs/zh/架构迁移评估.md §3stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面)
// 实施记录见 docs/zh/架构迁移评估.md子进程化论证与实验数据)
// 三个通信面:stdio JSON-RPC控制+ 共享内存段(数据)+ eventfd 通知环。
// 设计取舍见 assets/docs/zh/ARCHITECTURE.md子进程插件的三个通信面」。
// validateProcBinary 校验 plugin.bin 是否存在且可执行。
// 返回 ("", nil) 表示该目录不是 proc 插件。

View File

@ -7,7 +7,7 @@ import (
"testing"
)
// entry 分派(见 docs/zh/架构迁移评估.md 的入口双通道章节)
// entry 分派:按 manifest 的 entry 把插件分派到 proc / lua / skill 三条通道
//
// C ABI 通道(.so/.dll/.dylib已整体退场外部插件统一走子进程 + stdio RPC。
// 这些测试守住的是「旧产物给明确错误」而非「静默跳过」——后者会让

View File

@ -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.29ms5000 token + 20µs 慢消费者)

View File

@ -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 栈可带崩内核)

View File

@ -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
View File

@ -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