refactor(shm): 内核独占共享槽池,插件经 RPC 申请/归还(§13.2/§13.3)

推翻前两版跨进程分配器设计,根因是"共享内存里放了只被单一进程
更新的可变游标":

- v1 在 SuperBlock 放 arenaUsed 游标,内核 CAS bump。但插件模板里
  arenaUsed 是**进程本地变量**,两进程各自 bump 必写同一段内存;
  arenaReset 还会重置共享游标覆盖对方数据。
- v2 把位图 CAS 下沉到插件模板,正确但把分配器实现泄漏进插件运行时,
  且插件必须与内核保持位图布局同步。

新设计(用户明确的架构约束):内核全权管理共享内存,插件通过
syscall 风格 RPC 申请/归还,内核返回偏移与大小。分配器只存在于内核
进程内,一把 sync.Mutex 即可。共享内存是内部实现,不对插件开发者
暴露——SDK 公开 API 仍是普通字符串/Map。

主要改动:
- arena.go: 定长槽 + 位图,Alloc/Put/Read/Free/ReclaimOwner,内核独占;
  槽头记录 owner,Free 校验归属;payload 超槽容量退回内联 RPC
- protocol.go: 新增 arena.alloc / arena.free(CapCore)
- capability.go: 登记两个新 method(checkAllMethodsClassified 要求)
- corehandler.go: 实现 arena.alloc/free;Cleaner 改为内核预分配
  请求槽 + 响应槽(插件完全不分配)
- plugin.go: Start 领 ownerID;handleExit 调 ReclaimOwner 回收残留槽
- unified.go: 移除坏的 bump 分配器;arena 基址 8 字节对齐
- 删除 toollane.go: ring 状态机是死代码(从未接线),且把 RPC 已有的
  请求 ID 关联/错误传递/ctx 取消重新实现了一遍。控制面保留 RPC,
  只把 payload 搬进共享槽。
- 测试: TestArena_*(归属/回收/并发唯一/耗尽/超限/非法引用/布局)+
  TestPlugin_ArenaAllocFreeAcrossProcess(真进程申请→写入→随业务
  RPC 回传→归还→内核读回一致且池归零)
This commit is contained in:
JianFeeeee
2026-09-10 17:56:50 +08:00
parent cf3c99233b
commit ca584bda3f
13 changed files with 1157 additions and 654 deletions

View File

@ -38,13 +38,63 @@ var (
pendMu sync.Mutex
pending = map[uint64]chan json.RawMessage{}
shm []byte
shmSize int
region []byte // 完整统一区域 mmap
shm []byte
shmSize int
region []byte // 完整统一区域 mmap
arenaOff uint32
arenaUsed uint32
)
// ---- Exchange Arena 槽池(与内核 proc/arena.go 一致)----
const (
arOffSlotCount = 8
arOffSlotSize = 12
slotHeaderSize = 8
)
// arenaSlotSize 返回单个槽的总字节数(含 8 字节槽头)。
func arenaSlotSize() uint32 {
return binary.LittleEndian.Uint32(region[arenaOff+arOffSlotSize:])
}
// arenaPayloadCap 返回槽可承载的最大 payload。
func arenaPayloadCap() int { return int(arenaSlotSize()) - slotHeaderSize }
// ---- 共享槽池的插件侧接口(内核 RPC内部实现----
// SharedRef 是内核下发的共享内存描述符。
//
// 这是**内部实现**:真实插件不直接触碰它,模板运行时在传输层自动使用。
// 本测试插件手写 RPC所以需要显式声明。
type SharedRef struct {
Offset uint32 `json:"offset"`
Length uint32 `json:"length"`
Generation uint32 `json:"generation"`
Flags uint32 `json:"flags"`
}
func (r SharedRef) IsZero() bool { return r.Offset == 0 && r.Length == 0 }
// arenaAlloc 向内核申请一块共享内存,内核返回偏移与大小。
func arenaAlloc(size uint32) (SharedRef, error) {
raw := callKernel("arena.alloc", map[string]interface{}{"size": size})
var r struct {
Ref SharedRef `json:"ref"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return SharedRef{}, err
}
if r.Ref.IsZero() {
return SharedRef{}, fmt.Errorf("内核返回空引用")
}
return r.Ref, nil
}
// arenaFree 通知内核回收先前申请的共享内存。
func arenaFree(ref SharedRef) {
callKernel("arena.free", map[string]interface{}{"ref": ref})
}
func send(v interface{}) {
b, _ := json.Marshal(v)
writeMu.Lock()
@ -255,6 +305,11 @@ func main() {
"def": map[string]interface{}{"name": "demo_upper", "description": "转大写"},
"has_cleaner": true,
})
callKernel("tool.register", map[string]interface{}{
"name": "demo_inject",
"def": map[string]interface{}{"name": "demo_inject", "description": "共享内存注入"},
"has_cleaner": false,
})
callKernel("stage.register", map[string]interface{}{
"stage": "after_toolcall",
"scope": "global",
@ -275,24 +330,56 @@ func main() {
os.Exit(0)
case "tool.invoke":
var p struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
}
json.Unmarshal(req.Params, &p)
text, _ := p.Args["text"].(string)
send(response{ID: req.ID, Result: map[string]interface{}{
"result": strings.ToUpper(text),
}})
// 必须在独立 goroutine 里处理demo_inject 会在处理过程中反向
// 调用内核arena.alloc / io.injectText / arena.free而内核的
// 应答只能由主读循环接收。若同步处理就会自锁死——这也是真实
// 模板对每个内核请求都 `go handleKernelRequest` 的原因。
go func(id uint64, raw json.RawMessage) {
var p struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
}
json.Unmarshal(raw, &p)
text, _ := p.Args["text"].(string)
// demo_inject 走插件侧共享内存路径:
// 申请 → 写入 → 随业务 RPC 回传 → 归还。
if p.Name == "demo_inject" {
ref, err := arenaAlloc(uint32(len(text)))
if err != nil {
send(response{ID: id, Error: err.Error()})
return
}
copy(region[ref.Offset:ref.Offset+uint32(len(text))], text)
ref.Length = uint32(len(text))
callKernel("io.injectText", map[string]interface{}{
"source": "plugin", "channel": "demo", "text_ref": ref,
})
arenaFree(ref)
send(response{ID: id, Result: map[string]interface{}{"result": "injected"}})
return
}
send(response{ID: id, Result: map[string]interface{}{
"result": strings.ToUpper(text),
}})
}(req.ID, req.Params)
case "cleaner.invoke":
var p struct {
Scope string `json:"scope"`
Name string `json:"name"`
Text string `json:"text"`
TextRef struct {
Offset uint32 `json:"offset"`
Length uint32 `json:"length"`
Flags uint32 `json:"flags"`
} `json:"text_ref"`
RespRef struct {
Offset uint32 `json:"offset"`
Length uint32 `json:"length"`
Flags uint32 `json:"flags"`
} `json:"resp_ref"`
}
json.Unmarshal(req.Params, &p)
valid := (p.Scope == "tool" && p.Name == "demo_upper") ||
@ -302,23 +389,28 @@ func main() {
send(response{ID: req.ID, Error: "未注册 Cleaner"})
continue
}
// 从 arena 读文本
input := string(region[p.TextRef.Offset : p.TextRef.Offset+p.TextRef.Length])
output := p.Scope + "-cleaned:" + input
// 写回 arena
off := arenaUsed
if off == 0 {
off = 1
// 读输入:优先共享槽,否则内联。
input := p.Text
if p.TextRef.Length > 0 {
input = string(region[p.TextRef.Offset : p.TextRef.Offset+p.TextRef.Length])
}
end := off + uint32(len(output))
copy(region[arenaOff+end-uint32(len(output)):arenaOff+end], output)
arenaUsed = end
send(response{ID: req.ID, Result: map[string]interface{}{
"text_ref": map[string]interface{}{
"offset": arenaOff + off,
"length": uint32(len(output)),
},
}})
output := p.Scope + "-cleaned:" + input
// 写结果:有预分配槽且放得下就写槽,否则内联。
// 插件不分配任何槽(“谁分配谁释放”全部在内核侧)。
if p.RespRef.Offset > 0 && len(output) <= arenaPayloadCap() {
copy(region[p.RespRef.Offset:], output)
send(response{ID: req.ID, Result: map[string]interface{}{
"text_ref": map[string]interface{}{
"offset": p.RespRef.Offset,
"length": uint32(len(output)),
"flags": p.RespRef.Flags,
},
}})
continue
}
send(response{ID: req.ID, Result: map[string]interface{}{"text": output}})
case "stage.invoke":
go func(id uint64) {