feat(proc): Plugin 加载器 + 51 method handler + RunStage 接共享段(Part 2 完成 / Part 4 闭环)

corehandler.go —— cabi/loader.go 51 个 case 体的整块平移(§3.2):
- 参数从「s1/s2/s3 + i1/i2 五个固定槽」改为结构化 JSON,语义不变
- CoreSDK 接口刻意只含外部插件应得能力:无 Selftest/Supervisor/Tracker/
  Status/Adapter/Config/Tool/Indexer/OutputChan/Publish
  → 权限梯度从「C ABI 表达能力的意外产物」变成「显式声明并强制的策略」(§3.8)
- 事件订阅(case 23/24)与 SetToolBlocks 明确返回未实现,不再像 C ABI 那样静默成功
  (静默成功后收不到事件比报错更难排查)
- ToolDef.Cleaner / ChannelDef.Cleaner 是函数,跨进程置 nil(§3.5 回调型资源)

host.go —— 共享段所有权中心:
-  全部子进程插件共享**同一块 memfd**。若每插件一段,
  「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,
  最后回读者覆盖前者,§8.4 的 35.8~36.8% lost update 原样复现
- stageMu 串行化整次 stage 对段的独占(内核可能并发触发 RunStage)
- 首个进入者写入段,最后离开者回读 + Compact(此时无插件持锁,满足 §3.3 前提)

stage.go —— RunStage 接线(风险 3.4 落点):
- 并发扇出保留(§0.2 第 1 条:并发扇出是原始设计,不是缺陷)
- 插件失败时 ForceReleaseLock,避免后续插件死锁(实验 9,无需 robust mutex)

plugin.go —— registry 可加载的插件实体:
- Start: spawn(fd 3 传共享段)→ 握手 → plugin.init → plugin.start
- Close: **真 kill + wait**,对比 cabi 的 Close 只做 dlclose 而后者是 no-op(§1.1)
- invokeOutput **同步等真实结果**,失败上报 error —— §9.4 根治

验证(34 项测试全绿,含 -race,全部用真实子进程):
- 单插件 stage 读改写经共享段回到内核 StageContext
- sanitizer(改写) + weather(只读) 并发:清洗结果不被覆盖(现网场景)
- **5 个独立进程并发 append 同一 FinalText:5 个标记全部保留,零丢失零撕裂**
  (实验 8 在真实 RPC + 真实 RunStage 下的复刻)
- 工具注册可调用 / 输出通道真实失败上报 / start 期间反向调用
- 未知 method 与未实现能力被拒绝 / stage 外加锁被拒绝

接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
This commit is contained in:
dev
2026-09-02 11:34:33 +08:00
parent d62430a71b
commit 82dcc86173
8 changed files with 2366 additions and 0 deletions

View File

@ -0,0 +1,215 @@
package proc
import (
"fmt"
"log"
"os"
"sync"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
"golang.org/x/sys/unix"
)
// Host 持有**被全部子进程插件共享的一块 StageContext 段**,是共享内存数据面的
// 所有权中心§3.3/§3.4)。
//
// ❗ 为什么必须共享一块段(这是一个容易走错的关键点):
// 若每个插件各持一块段,则「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成
// 副本模型——两个插件各写各的段、各自回读,最后回读者覆盖前者,
// lost update 原样复现§8.4 实测 35.8~36.8%)。
// 实验 8 的做法是 5 个 worker 进程 mmap **同一个 memfd**,本实现与之一致。
//
// 生命周期Host 由 registry 创建一次,随内核存活;每个插件 spawn 时经
// ExtraFiles 拿到同一 memfdfd 3mmap 后即看到同一份物理页。
type Host struct {
memfd *os.File
data []byte
seg *Segment
shmSize int
// locks 被全部插件的 coreHandler 共享——同阶段并发扇出的插件在此排队,
// 语义等价于内置插件共享 *StageContext 的 sync.RWMutex§0.2 第 1 条)。
locks *lockRegistry
// stageMu 串行化「整次 stage 执行」对共享段的独占。
//
// 必要性:内核可能在不同路径并发触发 RunStage如 emitResponse 的
// before_output 与主循环的其他阶段)。段只有一份,两次 stage 交叠会互相污染。
// 由首个进入的插件加锁、最后离开的插件解锁RunStage 的 wg.Wait() 保证
// 每个 handler 的 defer 必然执行,故 inflight 必然归零,不会死锁。
stageMu sync.Mutex
coordMu sync.Mutex
coord *stageCoordinator
}
// NewHost 创建共享段memfd + mmap + 布局初始化)。
//
// 用 memfd 而非 /dev/shm 文件:无需文件名、不残留(进程退出即回收)、
// 可经 ExtraFiles 传给子进程。实验 2 已验证父子 mmap 到不同虚拟地址时
// 相对偏移仍正确解引用。
func NewHost() (*Host, error) {
fd, err := unix.MemfdCreate("hastagectx", unix.MFD_CLOEXEC)
if err != nil {
return nil, fmt.Errorf("proc: 创建共享段 memfd: %w", err)
}
if err := unix.Ftruncate(fd, int64(shmDefaultSize)); err != nil {
unix.Close(fd)
return nil, fmt.Errorf("proc: 共享段 ftruncate: %w", err)
}
data, err := unix.Mmap(fd, 0, shmDefaultSize,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
unix.Close(fd)
return nil, fmt.Errorf("proc: 共享段 mmap: %w", err)
}
seg, err := NewSegment(data)
if err != nil {
unix.Munmap(data)
unix.Close(fd)
return nil, err
}
return &Host{
memfd: os.NewFile(uintptr(fd), "hastagectx"),
data: data,
seg: seg,
shmSize: shmDefaultSize,
locks: &lockRegistry{},
}, nil
}
// shmDefaultSize 是共享 StageContext 段的大小。
//
// 取 256KBStageContext 全字段 JSON 化后典型 < 4KB工具结果中位 93B§2.5
// append-only 中间垃圾由 stage 结束时 Compact 回收256KB 给足余量。
// 全部插件共享一块,总开销恒定,不随插件数增长。
const shmDefaultSize = 256 * 1024
// Close 释放共享段。
func (h *Host) Close() error {
if h.data != nil {
unix.Munmap(h.data)
h.data = nil
}
if h.memfd != nil {
err := h.memfd.Close()
h.memfd = nil
return err
}
return nil
}
// beginStage 由插件 handler 进入时调用。
//
// 首个进入者:获取 stageMu独占共享段→ 把内核 StageContext 写入段。
// 后续进入者:仅递增 inflight。
func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) {
h.coordMu.Lock()
first := h.coord == nil
if first {
// 独占共享段直到本次 stage 全部插件离开
h.coordMu.Unlock()
h.stageMu.Lock()
h.coordMu.Lock()
// 双检:等锁期间可能已有其他插件建好协调器(它们会先拿到 stageMu
if h.coord != nil {
first = false
h.stageMu.Unlock()
} else {
h.coord = newStageCoordinator(h.seg)
}
}
coord := h.coord
h.coordMu.Unlock()
if err := coord.enter(sc, first); err != nil {
if first {
h.coordMu.Lock()
h.coord = nil
h.coordMu.Unlock()
h.stageMu.Unlock()
}
return nil, err
}
h.locks.bind(coord.lock)
return coord, nil
}
// endStage 由插件 handler 返回时调用。
// 最后离开者:把共享段结果读回内核 StageContext → 压实 arena → 释放 stageMu。
func (h *Host) endStage(coord *stageCoordinator) error {
last, err := coord.leave()
if !last {
return err
}
h.coordMu.Lock()
h.coord = nil
h.coordMu.Unlock()
h.stageMu.Unlock()
return err
}
// ForceReleaseLock 在插件进程崩溃时释放其可能持有的 stage 锁(实验 9 自愈机制)。
func (h *Host) ForceReleaseLock(plugin string) bool {
return h.locks.forceRelease(plugin)
}
// Segment 暴露共享段(供诊断与测试)。
func (h *Host) Segment() *Segment { return h.seg }
// stageCoordinator 跟踪一次 stage 执行中参与插件的进出。
type stageCoordinator struct {
seg *Segment
lock *stageLock
mu sync.Mutex
inflight int
written bool
ctxRef *pubsdk.StageContext
}
func newStageCoordinator(seg *Segment) *stageCoordinator {
return &stageCoordinator{seg: seg, lock: newStageLock()}
}
// enter 登记一个插件进入本次 stagefirst 为真时把内核状态写入共享段。
func (c *stageCoordinator) enter(sc *pubsdk.StageContext, first bool) error {
c.mu.Lock()
defer c.mu.Unlock()
c.inflight++
if !first || c.written {
return nil
}
c.ctxRef = sc
if err := c.seg.WriteAll(sc); err != nil {
c.inflight--
return fmt.Errorf("写入共享段: %w", err)
}
c.written = true
return nil
}
// leave 登记一个插件离开;返回是否为最后一个离开者。
//
// 最后离开者负责把共享段结果读回内核 StageContext并压实 arena
// (此时无插件持锁,满足 §3.3 的压实前提)。
func (c *stageCoordinator) leave() (last bool, err error) {
c.mu.Lock()
c.inflight--
last = c.inflight == 0
sc := c.ctxRef
written := c.written
c.mu.Unlock()
if !last || !written || sc == nil {
return last, nil
}
if rErr := c.seg.ReadInto(sc); rErr != nil {
return last, fmt.Errorf("回读共享段: %w", rErr)
}
if reclaimed := c.seg.Compact(); reclaimed > 0 {
log.Printf("[proc] stage 结束arena 压实回收 %d 字节", reclaimed)
}
return last, nil
}