Commit Graph

11 Commits

Author SHA1 Message Date
b096e8ba2c refactor(shm): 变长块分配器 + 工具调用 funccall 调用帧(§13.2/§13.3)
按架构约束推进两步:

1) 分配器收回内核后,定长槽失去了唯一存在理由
   (定长槽只是为了绕开"跨进程无法安全变长分配"),
   于是 arena 换成**变长块分配器**:first-fit + 邻块合并,
   块头 16B(size/state/owner/prevSize)。内核可以按需标定每块大小,
   payload 不再受固定槽容量限制(旧上限 16KB)。

   - Alloc/Put/Read/Free/ReclaimOwner 全在内核进程内,一把 sync.Mutex
   - Free 走块链校验 offset 是已分配块的数据起点 + owner 匹配,
     伪造引用不能改动分配器状态
   - Read 允许块内偏移(调用帧的结果区就在帧块中间),但不许跨块边界
   - arena 4MB,底层 memfd 惰性分配:未触碰的页不占物理内存

2) 工具调用 payload **始终**走共享内存,取消按大小切内联的分支。
   按 funccall 模型,内核(caller)标定调用帧交给插件(callee):

       [0, ArgsLen)              参数 JSON
       [ArgsLen, Frame.Length)   结果区(内核预留的预算)

   结果超出预算时插件才 `arena.alloc` 扩容块,引用上打
   sharedRefFlagExpand 让内核单独归还。Cleaner 复用同一帧模型。
   ToolInvokeParams.Args / ToolInvokeResult.Result 仅剩给直连 RPC 的
   测试(process/bench 不建 Host);生产路径永远走帧。

测试:
- TestArena_*(分配/归属/回收/并发唯一/耗尽/超限/伪造 offset/
  相邻合并/对齐/布局校验)
- TestPlugin_ToolInvokeArgsResultViaArena:大**小** payload 都经帧往返,
  结果一致且 arena 归零("小 payload 同样走调用帧"是本轮行为变更)
- TestPlugin_ArenaAllocFreeAcrossProcess、TestE2E_* 保持通过
- 顺手修 process_test.go 一处预存 doc-comment 分段
2026-09-10 18:54:36 +08:00
ca584bda3f 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 回传→归还→内核读回一致且池归零)
2026-09-10 17:56:50 +08:00
cf3c99233b fix(shm): 审核修复——arena 并发安全 + ToolCall ring 抢占 + arenaRead 校验
§13.2: arenaAlloc 改 CAS bump(修复并发覆盖);arenaRead 增加 generation + arena 边界校验
§13.3: Reserve 从 Store+Load 改 CAS-free→Reserved 状态机抢占帧(修复并发分配重复);
  增加 RESERVED 中间态;SetReading/SetReady 改 CAS 返回 bool;ReleaseFrame 清零跳过 state
§13.5/13.6: 注入方法接入 resolveText(从 arena 读 SharedRef);cleanerProxy 加 arenaMu 串行化+每次重置
§13.8: ContextPolicy prune topK 改为 maxContextSize-1(不再硬编码 20)
  tool.register 校验 context_policy 只允许 none/prune
2026-09-10 17:03:32 +08:00
9cfdfc9de8 feat(shm): arena + ContextPolicy(§13.2/§13.8)
- unified.go: arena 分配器 (alloc/write/read/reset)
- NewHost: 分配空间含 arena
- SDK ToolDef 加 ContextPolicy string
- StageAfterToolcall 后检查 prune 策略触发 RelevanceContext.Prune
2026-09-10 15:49:47 +08:00
ad016e4410 feat(shm): 统一共享内存区域(§13.1) — 单 memfd 容纳 SuperBlock+StageContext+EvtRing
- unified.go: SuperBlock 布局 + SharedRef 类型
- NewHost(): 两段 memfd 合并为单一 memfd,fd 3 = 统一区域,fd 4 = eventfd
- 子进程侧: testdata 插件从 SuperBlock 解析 ctxOff/evtOff
- streaming 测试: EvtConsumer 协程在 host.Close 前 Stop+Wait 防 SIGSEGV
2026-09-10 11:42:22 +08:00
e272c686d3 fix(proc): stage 协调器双重解锁——内核本体 fatal 崩溃的真因
## 现象

2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2,
带走全部 27 个子进程插件。

  fatal error: sync: unlock of unlocked mutex
  proc.(*Host).endStage(...)             host.go:189
  proc.(*coreHandler).runStage.func1()   stage.go:94
  core.(*StageHost).RunStage.func1()     stages.go:190

stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」
而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic,
recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、
内核本体整体死亡的原因。

## 根因

endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在
coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口:

  A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
  B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
                (后到者按设计不取 stageMu)
  A.endStage: h.coord = nil;stageMu.Unlock()                    ← 第 1 次
  B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥

B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」,
对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻
正好有 stage 扇出,与竞态窗口重合。

## 修复

把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu
临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成:
  - depart():纯计数,由 endStage 在 coordMu 内调用
  - finish():共享段回读 + arena 压实,在 coordMu 外、但仍在
    stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段)
leave() 保留给单测。

同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段)
原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。
现在 enter() 在锁内完成。

beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明:
runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后**
才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。

锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。

## 验证

反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of
unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。

5 个回归用例(host_stage_test.go):
  - 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合)
  - 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL)
  - 同阶段多插件扇出共用一个协调器、仅最后离开者解锁
  - 50 轮串行不泄漏(少解锁会在第二轮卡死)
  - 四阶段序列 pre_action→chat→after_toolcall→post_action

internal/plugin/... 全量 -race -count=2 通过。

## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论)

针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计:

1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数)
   - proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」,
     但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回,
     不存在双解锁路径。
   - 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自
     解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、
     lua adapterPool 的 cond.Wait 池模式等),配对正确。
2. 并发 map 读写(同样是 runtime fatal)
   - 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、
     defsLockedRegisterSource)、调用方持锁(document 的 addSummary/
     removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、
     或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。
3. close of closed channel
   - 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员,
     且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete
     出 pending,不会双关。
   - 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用
     stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。
     agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处
     Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再
     Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。
4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。
5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。
2026-09-04 18:43:05 +08:00
02cc74ce11 fix(proc): 子进程崩溃自愈 + 集中台账 + 注册面摘除
根因:子进程插件被 kill 后,内核只发了一个无人订阅的事件,
工具/stage handler/IO 通道全留在注册表里指向死进程,
模型继续调用只吃 ErrProcessExited,没有任何路径把插件拉回来。

## 四层修复

### 1. 专职 waitLoop(进程收割)
- 每个子进程配一根 waitLoop goroutine,是 cmd.Wait() 的唯一调用点
- 不再依赖 stdout EOF 判定死亡(孙子进程继承 stdout 时 EOF 永不到来)
- 手工 os.Pipe 替代 cmd.StdinPipe/StdoutPipe,避免 waitLoop 与
  os/exec 的内部关闭竞争
- host.go: Host.Supervisor(),Host.Close() 先 StopAll 再拆段

### 2. 集中台账 Supervisor
- proc/supervisor.go: 插件 Spawn 握手成功即 track,进程退出即 untrack
- StopAll: 并发发 plugin.stop 走优雅路径,到期仍在的一律 Kill
- 关停后才完成握手的进程被立即结束,不会活过内核
- 消除「孤儿进程持共享段映射 → SIGBUS」的隐患

### 3. 注册面摘除(detachPlugin)
- 新增 StageHost.UnregisterPluginStages:摘除指定插件的全部 stage handler
- 新增 Registry.pluginChannels 台账:记录每个插件注册的 IO 通道
- 三条路径统一走 detachPlugin:Disable / ReloadOne / RemovePlugin
- StopAndUnload 漏了 IO 通道也一并补上

### 4. 自动重启
- onProcCrash 从「只发事件」改为「摘注册面 → 从注册表移除 → 异步排重启」
- scheduleProcRestart: 窗口 5 分钟内最多 3 次,线性退避 1s/2s/3s
- 超限停手留日志;重启前复核是否已被 Disable 或被其他路径加载
- 崩溃计数窗口过期自动归零

### 5. 主动停止 vs 崩溃的区分
- proc.Plugin 新增 stopping 标志:Stop()/Close() 里 Set(true)
- handleExit 读 stopping 标志,主动停止不上报 onCrash
- 防止重载/禁用/卸载被误判为崩溃触发多余重启

### 6. Linux Pdeathsig 兜底
- procattr_linux.go: SysProcAttr.Pdeathsig = SIGKILL
- 兜 homed 自身被 SIGKILL/OOM 时子进程变孤儿的场景
- macOS/Windows 无等价物,空实现

### 7. pluginmgr 升级
- PluginManager 接口新增 PluginRuntime / ListPluginRuntimes
- plugin_list 输出运行态:loaded / alive / pid / crash_count / channel
- 新增 plugin_status: 全量运行期快照 + dead/unhealthy 汇总
- 新增 plugin_restart: 无条件重启单个插件(plgreload 不动未改二进制的插件)

### 测试
- process_test.go: 3 例(grandchild stdout 感知 / Supervisor track-untrack /
  StopAll 无孤儿)
- crash_recovery_test.go: 8 例(detach 三项齐全 / 通道重注册 / 崩溃不阻塞 /
  退避阈值 / 窗口过期 / 关停中跳过 / PluginRuntime 通道识别)
- stages_plugin_test.go: 4 例(stage 按插件摘除 / 空 stage 清理 / 空名 no-op /
  工具+stage 双摘后可重新注册同名)
2026-09-03 12:37:58 +08:00
d027c964e2 proc: Windows 共享内存 + 事件通知适配(Part 6.2 内核侧)
补齐内核侧的 Windows 创建端,与 6.1 的插件侧打开端配对。三平台
(linux/darwin/windows)现在都能构建 internal/plugin/proc。

## Windows 走命名内核对象(无 fd 继承语义)

os/exec 的 ExtraFiles 在 Windows 实现里不被支持,故:
- shmalloc_windows.go:CreateFileMappingW(INVALID_HANDLE_VALUE + 命名 →
  系统页文件支撑的匿名段,不落盘)+ MapViewOfFile
- evtfd_windows.go:CreateEventW 命名 Event 对象 + SetEvent 通知
- shmpass_windows.go:把段名/对象名经环境变量注入子进程
  (HOMEAGENT_SHM_STAGE / HOMEAGENT_SHM_EVTRING / HOMEAGENT_EVT_EVENT)

名字带 PID + 递增序号:多个 homed 实例并存时不能撞名。

Event 与 eventfd 的语义差异:Event 是二元信号,多次 SetEvent 只对应一次
唤醒,不累积。不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq 批量
drain,丢的是"唤醒次数"不是"事件";事件环本身就允许溢出丢弃并让消费者
知道丢了(dropped 计数),通知面从来不是可靠投递语义。

## 传递机制抽象为 shmpass_*.go

Plugin.Start 不再直接构造 ExtraFiles 列表,改为问 Host 要:
  Env:        p.host.procEnvForShm()        // Windows 返回段名,Unix 返回 nil
  ExtraFiles: p.host.procExtraFilesForShm() // Unix 返回 fd 列表,Windows 返回 nil

平台差异被收敛到这一对函数,Plugin/coreHandler/stage 全部平台无关。

## macOS pipe 生命周期修正

原实现只返回读端 fd,写端 *os.File 无人持有 → 可能被 GC 回收 →
读端收到 EOF 而非阻塞 → 消费循环变忙转。改为 pipePair 表同时持有两端,
evtfdClose 一并关闭。

## E2E 测试跟进模板拆分

模板从单文件拆成三个(主体 + unix/windows 挂载),测试需要一并落盘,
否则编译报 attachStageShm undefined。procRuntimeTemplates 表必须与
SDK 仓 proc_runtime.go 的 procRuntimeFiles 一致。

验证:三平台 go build ./internal/plugin/... 通过(gojieba 的 cgo 依赖
导致 internal/memory 在非 linux 失败,与本次无关);
go test -race ./internal/plugin/... 全绿,含 2 项真实模板 E2E。

Ref: docs/zh/架构迁移评估.md §9.2、docs/zh/plugin-migration-plan.md Part 6
2026-09-02 19:07:14 +08:00
5bbfcc02fb plugin: 事件环内核侧实现(§3.6 Part 5 核心)
事件环(EvtRing)是子进程首次获得事件订阅能力的基础设施。
此前 case 23/24 明确返回未实现,现在经事件环真正可用。

核心设计(§3.6,实验 4 已验证 post-and-forget 加速比 2218x):
- 事件环放**独立共享段**(不与 StageContext 混放):stage compact 会清 arena,
  事件要独立于 stage 生命周期。Host 持有两块 memfd:fd 3 = StageContext,
  fd 4 = 事件环段,fd 5 = eventfd。
- 无锁数据结构:内核 WritePush 追加写 slot,子进程 EvtConsumer 消费。
  writeSeq 原子递增(Bus.Publish 并发调用),readSeq 每订阅者独立。
- eventfd 通知:Linux 用 unix.Eventfd(计数合并,1000 token 只唤醒几次),
  macOS 用 os.Pipe(阻塞模式走 netpoller,只 park goroutine,实验 1 验证
  200 等待者仅 +1 OS 线程)。两者行为一致:Read 阻塞直到有新事件。
- 溢出语义:落后超 cap 时跳到最新,丢弃计数记入 dropped(消费者知道丢了)。
  不静默覆盖最旧(写端直接覆盖 slot,读端靠 seq 判断跳过)。
- 事件类型编码:pubsdk.EventType 字符串 ↔ uint32 位索引(编译时映射表),
  typeMask 位掩码过滤(1<<idx)。

Host 改动:
- NewHost 同时创建事件环段和 eventfd(惰创建,一次分配)。
- Host 持有 evtSubscriber 接口(EvtRingSubscriber),由 Registry 注入
  EventRing 实现——proc 包不依赖 internal/plugin(避免循环依赖)。

corehandler 改动:
- events.subscribe(原 case 23):子进程传事件类型列表,coreHandler
  通过 evtRing 接口调用 EvtRingSubscribe,注册到 Bus 上。
  事件经 EventRing 写入环后由子进程 mmap 读取。
- events.unsubscribe(原 case 24):当前由内核统一清理(子进程 Stop 时)。

Registry 改动:
- ensureProcHost 在创建 Host 后同时创建 EventRing(Bus → EvtRing → eventfd),
  并通过 Host.SetEvtSubscriber 注入给 coreHandler。

测试 3 项:
- BasicWriteAndConsume:Host 创建 → EventRing 写入 → 消费者读到
- OverflowStillDelivers:写入超过 cap 后消费者仍能读到最新事件
- TypeMaskFiltering:typeMask 只订阅 tool_call,agent_output 被过滤

验证:go build ./... 通过;go test -race ./internal/plugin/... 全绿;
既有事件环测试 3/3 通过;proc 包测试未受影响。

Ref: docs/zh/架构迁移评估.md §3.6、docs/zh/plugin-migration-plan.md Part 5
2026-09-02 16:48:27 +08:00
11c1bbcebb plugin: 子进程通道接通 registry(proc 通道端到端可运行)
Part 3 收尾。tryLoadProc 从桩位变成真实加载路径,plugin.bin 插件现在
经 registry 完整跑起来:spawn → 握手(共享段 fd 3)→ init/start →
反向注册 → 工具调用 → stage 共享内存读改写。

registry 侧:
- Registry 持有 procHost(惰性创建,**全部 .bin 插件共用一块段**)。
  每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成
  副本模型,lost update 原样复现(§8.4 实测 35.8~36.8%)。
- tryDynamic 分派到 Registry.loadProc;tryLoadProc 退为纯静态校验
  (构造需要 Host,只有 Registry 有)。
- StopAll 在锁外释放共享段:插件还持有映射时拆段,它们下一次访问就是
  SIGBUS;且持锁调用会与 onProcCrash 回调产生锁序风险。
- onProcCrash 把子进程退出转成 EventSystem 事件,不在回调里直接重载
  (重载需 registry 锁,而回调可能来自持锁路径的 goroutine)。

proc_core.go —— 权限梯度的类型系统落点(§3.8):
- procCore 用**命名字段**持有 *isdk.PluginSDK,不是嵌入。嵌入会提升全部
  方法,外部插件就能经类型断言拿到 Supervisor/Tracker/Adapter/Indexer/
  Status/Selftest。命名字段下只有显式写出的方法存在——权限梯度从
  「C ABI 表达能力的意外产物」变成显式声明并强制的策略。
- 能力访问器把内部超集接口收窄到公开面(isdk.KnowledgeAPI 内嵌
  pubsdk.KnowledgeAPI 再加 Stats/Remove,isdk.MemoryAPI 加 GraphData,
  isdk.LLMAPI 加 Chat/ReloadFromConfig);nil 保护避免类型化 nil 让
  corehandler 的判空失效。
- procPluginAdapter 转接 Start(*isdk.PluginSDK) → Start(proc.CoreSDK),
  Close 对 closeDynamic 可见故重载能真 kill 子进程(对比 dlclose 对
  Go c-shared 是 no-op,§1.1)。

共享段分配按平台拆分(原先 host.go 直接调 unix.MemfdCreate,darwin/windows
交叉编译失败):Linux memfd;macOS 立即 unlink 的临时文件(无 memfd_create,
但语义一致:无残留、fd 可经 ExtraFiles 传递、子进程 mmap 同一 inode);
其余平台明确报错而非静默降级成「无共享段」——那会让 stage 静默失去数据面。

测试 +13 项:
- e2e_template_test.go 用**真实 plugindev 模板**(而非 testdata 手写假插件)
  编译插件跑全链路,验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己
  跟自己对齐。含 lifecycle.autoRestart 上报、工具调用、stage 读改写、
  FinalText 回传(C ABI 下 after_toolcall 看不到此字段,§8.3 10→16)、
  只读插件不覆盖改写插件。
- proc_load_test.go 验证 Host 唯一性/惰性、chmod +x 错误提示、
  Close 可见性,以及 procCore 不暴露内核内部机制的断言。

验证:go build ./... 通过;go test -race ./internal/plugin/... 全绿;
全仓 go test 无新增失败;git diff third_party/homeagent-sdk/sdk/ 为空。
既有告警 cabi/loader.go:156 unsafe.Pointer 非本次引入。

Ref: docs/zh/架构迁移评估.md §3.3/§3.4/§3.8、docs/zh/plugin-migration-plan.md Part 3
2026-09-02 13:03:57 +08:00
dev
82dcc86173 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/ 为空
2026-09-02 11:34:33 +08:00