- docs/zh/架构迁移评估.md: C ABI→子进程+共享内存完整迁移论证(1621行) - docs/zh/experiments/: 18项可复跑可行性实验(架构评估的所有数字来源) - plan.md §11: 11.1~11.9 插件架构缺陷修复清单(唯一权威编号) - main 保持干净,本批次为 update 特性分支的整改起点
72 KiB
插件架构迁移评估:从 C ABI 动态库到子进程 + 共享内存
状态:评估稿 + 三轮验证已完成(18 项可复跑实验) · 第七章 可行性实验:11 项全部通过(另有 01 组 3 项 + 04 组 2 项,合计 18) · 第八章 代码检查:发现外部插件 stage 一直是副本模型,实测 36.8% lost update · 第九章 补盲分析:新发现 4 类缺陷,其中 2 项正在生产环境造成故障
❗ 现网正在发生的问题(详见 9.3/9.4/8.6):
output_send永远返回成功(已 2 次)、cgo 超时线性泄漏(已 26 次)、stage 数据污染(量级百分之几)结论摘要:现有
c-shared + dlopen架构存在无法修复的热重载缺陷与能力天花板, 两者同源于 C ABI 这一前提。迁移到子进程模型可一次性消除,并让 C 中间层整体退场。可执行修复项与勾选清单见
plan.md第 11 节;本文档负责论证、实验与架构设计。
零、给接手者的阅读指引
先读本章再读其余部分,否则极易被前六章的过时表述误导。
0.1 本文档是增量写成的,前后章节结论不同
文档按三轮工作递进追加,前面的章节保留了当时的认识(便于追溯推理过程), 但其中若干结论已被后续章节推翻或修订。冲突时一律以编号更大的章节为准。
| 章 | 写作时的信息基础 | 可信度 |
|---|---|---|
| 一 ~ 六 | 代码阅读 + 推理 | ⚠️ 部分已被推翻,见 10.2 对照表 |
| 七 | 11 项新架构可行性实验 | ✅ 实测 |
| 八 | 精读 ABI 链路 + 复刻实验 | ✅ 实测,推翻了 2.4 的核心前提 |
| 九 | 遍历全部加载路径 + journal 统计 | ✅ 实测 + 现网数据 |
| 十 | 元信息 | — |
最重要的一处推翻:2.4 节称「stage 并发扇出改写同一 StageContext」,
该表述对内置插件成立,但外部 .so 插件从未共享过 StageContext——
它们走「快照-副本-写回」(见 8.1)。若按 2.4 的字面理解去改代码会走错方向。
0.2 三件事的准确定位
接手时最容易混淆的三个概念,这里一次说清:
① 并发扇出是原始设计,不是缺陷。
stages.go:124 用 go func + wg.Wait() 并发调用所有 stage handler,
StageContext 的 sync.RWMutex 与公开的 Lock/RLock 就是为此准备的协作机制。
设计是对的。 问题在于 C ABI 无法传递 Go 对象引用,
外部插件被降级为副本模型,那把为协作而生的锁在 ABI 边界外变成空转
(实测:同一并发设计下内置 0% 丢失,副本 35.8~36.8% 丢失)。
② 内置插件的高权限是刻意设计,不是"自己人所以安全"。
但当前实现把「应有的权限梯度」与「C ABI 的表达能力天花板」混在了一起:
外部插件拿不到 OutputChan/Subscribe 是技术限制(Go channel、闭包
过不了 C ABI),而非权限决定——证据是 loader.go 的 case 23/24
(事件订阅)是空实现,属于"给不了"而非"不给"。
迁移目标是让梯度从技术意外变成显式声明并强制的策略,不是消除梯度。
③ 副本模型是"为方便插件加载的无奈之举",不是设计失误。
C ABI 是为绕开 Go 原生 plugin 包的同版本限制而引入的,副本模型是它的必然代价。
批评应指向"该代价未被记录、其后果(lost update)未被发现",而非当初的选择。
0.3 修复项以 plan.md 为唯一权威
本文档出现过多套编号(4.1 的 0.x、8.9、9.6 的 A-F),
均已统一收敛到 plan.md 第 11 节 的 11.1~11.6。
| plan.md | 内容 | 本文档论证位置 |
|---|---|---|
| 11.1 | output_send 假成功 |
9.4 |
| 11.2 | cgo 超时不可中断 | 9.3 |
| 11.3 | stage 副本 lost update | 8.1~8.6 |
| 11.4 | Lua 缺读锁 | 9.1 |
| 11.5 | Windows 能力退化 | 9.2 |
| 11.6 | reload 语义谎言 | 1.1 / 1.2 |
| 11.7 | 子进程化迁移(待决策) | 三~七章 |
本文档中的 0.x / A-F 编号仅供追溯当时的分组思路,实施时不要使用。
0.4 哪些结论未经实测
| 表述 | 状态 |
|---|---|
| 9.2 Windows DLL 只下发 3 字段且无写回 | ⚠️ 仅代码阅读,无 Windows 环境实测 |
| 11.1 修复方案「不构成 cgo 嵌套」 | ⚠️ 推理,实施前必须实测 |
| 9.1 Lua DATA RACE 会实际触发 | ⚠️ 现网无 Lua 插件,未触发过 |
| 3.x 目标架构的全部设计细节 | ⚠️ 机制经实验验证,完整实现未写 |
其余带 ✅ 的均有 experiments/plugin-arch/ 下的
可复跑实验支撑(./run.sh,18 项)。
0.5 现网正在发生的问题(若只读一段,读这段)
| 问题 | 现网次数 | 影响 | 修复 |
|---|---|---|---|
output_send 永远返回成功 |
7 天内 2 次 | 消息发不出,模型以为成功、不重试 | 11.1 |
| cgo 超时不可中断 | 14 天内 26 次 | 每次泄漏 1 goroutine + 1 OS 线程,永久 | 11.2 |
| stage 清洗结果被覆盖 | 概率性,量级百分之几 | 脏数据(ANSI 转义)进 LLM 上下文 | 11.3 |
这三项都不需要等迁移决策,可独立修复。
一、为什么要动
1.1 触发问题:插件热重载静默失效
更换 plugin.so 后调用 plgreload,内核报告 reloaded: qq 成功,但运行的仍是旧代码。
根因经实验确证:
readelf -d plugin.so
FLAGS: SYMBOLIC STATIC_TLS
FLAGS_1: NODELETE ← Go 链接器强制写入
DF_1_NODELETE 使 dlclose 成为 no-op(返回 0 但不卸载)。同路径二次 dlopen
复用旧映像,新代码永不生效。
三组对照实验(/proc/self/maps 段数为准):
| 场景 | dlclose 后映射段数 | 结论 |
|---|---|---|
第三层是纯 C .so |
5 → 0 | 可真正卸载,换代码生效 |
| Go c-shared,Go 宿主直接 dlopen | 5 → 5 | 未卸载 |
| Go c-shared,经纯 C shim dlopen | 5 → 5 | 仍未卸载 |
第三行是决定性的:NODELETE 属于被卸载对象自身的 ELF 属性,与谁调用 dlopen 无关。
套任何层数的 C 中间件都绕不过去。
上游明确不支持(golang/go#11100):Go runtime 的信号处理器是进程全局的,
sysmon/GC worker/scavenger 常驻 OS 线程,静态 TLS 嵌进线程布局——
patch 掉标记只会把静默失效换成随机崩溃。
1.2 曾评估并否决的绕行方案
版本化路径 dlopen(plugins/qq/.load/plugin-<时间戳>-qq.so):技术上成立,实测有效。
1) 装载 1700000001-qq.so handle=0x36add080 ver=v1-CODE
2) 主 so 更新为 v2,复制到 1700000002-qq.so
3) dlclose 旧句柄 rc=0(旧映像不释放,预期)
4) 装载新路径 handle=0x36ade440 ver=v2-CODE ← 新代码生效
同时确认 Go c-shared 无 SONAME,不会被 glibc 按名去重,换路径确实得到新映像。
但代价不可接受。30 次连续重载实测:
第 10 次: RSS +15380KB threads +42
第 20 次: RSS +32048KB threads +112
第 30 次: RSS +46644KB threads +168
均摊: RSS +1575KB/次, 线程 +5.77/次
每次重载永久泄漏约 5.8 个线程——每份残留 Go runtime 都带自己的 sysmon、
GC worker、scavenger,永不退出且仍被调度。GOMAXPROCS=1 只能压到 4.0/次,
且会拖慢插件并发,杯水车薪。
对 24/7 常驻的 homed 而言,「永久泄漏」比「15 秒重启」糟糕得多:重启有界且自愈, 泄漏无界且单调劣化。故否决。
1.3 更深的问题:内置与外部插件的能力断层
| 方法数 | |
|---|---|
internal/sdk(内置插件用) |
28 |
| 公开 SDK(外部插件用) | 36 |
数字接近,但内置独有的恰恰是**「活的 Go 对象」**:
OutputChan() <-chan *agentIO.OutputEvent Go channel
RegisterChannel(dev agentIO.Device) Go 接口(含方法集)
Subscribe(type, handler) func() 回调 + 返回退订闭包
Publish / Config / Tool / Indexer 直接持有内核注册表
Selftest / Status / Supervisor / Tracker 内核内部机制
SetToolBlocks 多模态注入
关键区分:内置插件的高权限是刻意的设计决策,不是"自己人所以安全"。 但当前实现把两件事混在了一起:
- 应该有的权限梯度(
Supervisor/Tracker/Selftest只给内核内部) - C ABI 的表达能力天花板(channel、接口方法集、闭包在进程边界外无表示)
外部插件拿不到 OutputChan 是技术限制,不是权限决定。证据:
case 23: // CORE_SUBSCRIBE
// Events API not wired for external plugins
return 0
case 24: // CORE_UNSUBSCRIBE
return 0
事件订阅对外部插件是空实现。这不是"不给",是"给不了"。
迁移的价值不是消除权限梯度,而是让梯度从技术意外变成显式声明并强制的策略。
1.4 附带缺陷(同源于 C ABI)
SetToolBlocks在 bridge 中是空实现——跨 ABI 无对应 method id- 插件 panic 跨 C 栈,
recover兜不住就带崩整个 homed plugin_install返回reload_required对.so是误导性谎言- method id 编号出现历史断裂(
CORE_INJECT_INPUT_SYNC=47夹在 7 和 8 之间) plugindev交叉编译需处理 cgo 工具链,Windows/ARM 目标需对应 C 编译器
这些全是 C ABI 这一前提衍生的附属债务。前提一撤,债务自行消失。
二、现状盘点
2.1 插件规模
内置插件 16 个(internal/plugins/all.go 匿名 import + init() 自注册):
agentcli ai_image cfgmgr clawhubadapter cli cmd files healthcheck
localuse mcp multimodal pluginmgr remotedevice skillmgr timer webui
外部 .so 插件 17 个(/home/newqqagent/plugins/):
a2a acp ai_image bili browser calendar editdoc files memo
music ocr qq recoverydiag rss sanitizer vanblog weather
2.2 涉及代码规模
| 文件 | 行数 | 迁移后命运 |
|---|---|---|
internal/plugin/cabi/loader.go |
951 | 删除 |
internal/plugin/cabi/types.go |
66 | 删除 |
internal/plugin/cabi/loader.c |
79 | 删除 |
internal/plugin/registry.go |
922 | 改:加载分派 |
internal/plugin/dynamic_loader_unix.go |
79 | 删除/替换 |
internal/sdk/plugin.go |
365 | 基本不动 |
公开 sdk/plugin.go |
484 | 加访问器,签名不变 |
internal/agent/core/stages.go |
189 | 改:stage 跨进程 |
internal/agent/core/plugin_health.go |
128 | 不动,只换信号源 |
internal/events/bus.go |
97 | 加:事件环投递 |
plugindev/templates.go 的 tmplLinuxBridge |
385 | 删除(每插件一份) |
internal/plugin/lua_plugin.go |
978 | 改:统一走 RPC(见 9.1) |
internal/plugin/dynamic_lua.go |
24 | 改 |
internal/plugin/lua_util.go |
60 | 保留 |
internal/plugin/dynamic_dll_windows.go |
— | 删除(见 9.2,能力严重退化) |
internal/plugin/dynamic_loader_windows.go |
— | 删除 |
internal/plugin/dynamic_dll_stub.go |
— | 删除 |
⚠️ 第九章更正:此前只识别了 native + cabi 两类加载路径,实际有四种 (cabi
.so/.dylib/.dll、Luamain.lua、SkillSKILL.md、native 内置)。 Windows DLL 与 Lua 路径此前完全未评估,均存在独立缺陷(9.1/9.2)。 子进程化的一个重要收益是把三套独立 ABI 实现收敛为单一 RPC 实现。
可删除总量:1096 行 C ABI 层 + 385 行/插件的 bridge 模板。
loader.go 内 C.CString/C.GoString/C.free 共 38 处调用,纯边界税。
2.3 已有且完善、迁移时应保留的机制
必须强调:现有 SDK 的生命周期管理远比表面完善,迁移是"重新接线"而非"重写"。
plugin_health.go(128 行,逻辑完全复用):
3 次崩溃 / 5 分钟窗口 → 标记 unhealthy
30 秒冷却 → 自动恢复
pendingReloads() → 驱动 autoReloadPlugins
尊重 AutoRestartEnabled
executeToolCall(toolcall.go:16):
done := make(chan string, 1)
go func() { done <- a.executeToolCallInner(tc) }()
select {
case result := <-done: return result
case <-time.After(60 * time.Second): // 60 秒超时
}
// + panic 捕获 → resolveToolPlugin → recordCrash
SDK 停止链路(已验证被正确调用):
Handle.Stop() → call_stop_plugin → go_stop_plugin
→ sdk.RunStopHandlers() → plg.Stop()
RegisterStopHandler / RegisterOnRemoveHandler / SetAutoRestart
迁移时的唯一改动:把"panic 捕获"换成"进程退出码/EOF 检测",喂给同一个 recordCrash。
超时、冷却、自愈、优雅停止全部保持原样。
2.4 两条硬约束(决定新架构设计)
约束 A:stage 是并发扇出,多插件并发改写同一对象
internal/agent/core/stages.go:124:
func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
var wg sync.WaitGroup
for _, handler := range handlers {
wg.Add(1)
go func(fn sdk.StageHandler) { // ← 并发
defer wg.Done()
if err := fn(ctx); err != nil { errCh <- err }
}(handler)
}
wg.Wait()
}
所有插件 handler 并发运行在同一个 *StageContext 上,靠 sync.RWMutex +
公开的 Lock/RLock/Unlock 协调。这是刻意设计——那 17 个字段
(LLMText/ToolCalls/FinalText/Errors…)就是给多插件协作改写消息体用的。
这是共享内存的正当性所在。 JSON-RPC 副本模型下语义会崩坏:两个插件都改了
FinalText,谁赢?现在的答案是"后者看到前者结果",可组合;副本合并则无解。
⚠️ 本节前提已被第八章推翻,勿据此改代码
上述「并发扇出改写同一对象」只对内置插件成立。 外部
.so插件一直走「快照-副本-写回」(loader.go:411-439+templates.go:768-787), 其ctx.Lock()是空操作(锁的是副本自己的 mu),且实测存在 35.8~36.8% 的 lost update(实验 12)与现网量级百分之几的数据污染(实验 13)。两点务必分清:
- 并发扇出本身是正确的原始设计(
stages.go:124),RWMutex就是为它准备的- 失效的是跨 ABI 边界后的锁语义,不是这个设计
故共享内存的作用是修复副本模型的缺陷,而非"保持现有语义"。 完整分析见 8.1~8.6;准确定位见 0.2。
约束 B:Bus.Publish 是同步的,且流式输出每 token 发一次
internal/events/bus.go:56:
func (b *Bus) Publish(evt *Event) {
for _, h := range typeHandlers {
b.safeCall(h, evt) // ← 内联阻塞调用
}
}
EventContentDelta / EventReasoningDelta 在 accumulateStream 里逐 token 发布
(process.go:388/470/479)。
若内核发通知时等待插件,流式输出会被拖成卡顿。 故新架构的事件投递必须严格 post-and-forget,绝不等待消费者。
2.5 payload 体积实测(决定共享内存的定位)
近两日工具调用结果:
样本=96 中位=93 B p90=130 B 最大=134 B 均值=83 B
此量级下 JSON 序列化 3-8 µs,LLM 单轮往返 2-8 秒,IPC 开销占比约 0.0001%。
结论:共享内存的价值不在省序列化开销,而在两点:
- 并发改写同一份
StageContext(约束 A) - 二进制零拷贝(未来多媒体 payload,避免 base64 的 +33% 体积与编解码)
控制面用 JSON 完全够用——toolcall 结果最终都要 JSON 化交给模型。
三、目标架构
今天:
homed ──dlopen──> plugin.so
├─ cgo bridge 385 行(7 个 //export,27 处字符串转换)
└─ 51 个整数 method id 派发
↑ C 层唯一目的:绕开 Go plugin 包的同版本限制
之后:
homed ──spawn──> plugin(纯 Go 二进制,无 cgo)
│
├── stdio JSON-RPC 控制面:51 个 case 平移为 method 名
├── shm + 偏移 数据面:StageContext 并发改写、二进制零拷贝
└── eventfd 通知面:事件环 post-and-forget
3.1 C 中间层为何整体退场
C 层存在的唯一理由是绕开 Go 原生 plugin 包的版本枷锁:
plugin.Open 要求:完全相同的 Go 版本 + 完全相同的依赖版本 + 同构建环境
任一不符 → "plugin was built with a different version of package ..."
c-shared + dlopen 把接口面压成 C ABI 来规避,代价是 51 个整数派发和满地字符串转换。
子进程模型下,进程边界本身就是 ABI 边界。 两进程各带自己的 Go runtime, 版本/依赖/编译器全不相关——从根上不存在"同版本"问题。C 层解决的问题消失,C 层自己也就该消失。
连带消失的:DF_1_NODELETE 议题、版本化路径、重载配额、hash 目录、
method id 编号维护、SetToolBlocks 空实现、cgo 交叉编译工具链。
3.2 method id 的处置
51 个 case 不删,原样映射为 RPC method 名:
case 17: // CORE_SETTINGS_SET → {"method": "settings.set"}
case 41: // CORE_TEXT_MEMORY_APPEND → {"method": "textmemory.append"}
case 48: // CORE_PLUGIN_RELOAD_ONE → {"method": "plugin.reloadOne"}
每个 case 体(参数解析、调用、错误处理)可整块搬移,只换取参数方式。 语义不变,回归风险最小。
但编号本身扔掉:不再维护"下一个可用 id 是 52",加能力不用改两边常量表,
也不再出现 47 夹在 7 和 8 之间的历史痕迹。
3.3 数据面:偏移替代指针
共享段 = 定长头 + arena,所有变长数据用相对 arena 基址的 {off, len} 描述符。
相对偏移是关键——各进程 mmap 到不同虚拟地址也能正确解引用。
typedef struct { uint32_t off, len; } Slice; // 相对 arena 基址
typedef struct {
Slice type, text;
uint8_t has_image, has_audio;
Slice image_url, image_detail;
Slice audio_url;
} ShmContentBlock;
typedef struct {
uint64_t seq; // 乐观读校验
Slice raw_message, llm_text, reasoning, final_text;
uint8_t has_response; Slice response;
Slice media_type, input_source, output_channel;
uint32_t nblocks; Slice blocks; // → ShmContentBlock[]
uint32_t arena_used, arena_cap;
} ShmStageCtx;
Extra 可完全偏移化——已核实全部使用点只有 4 个键:
internal/agent/core/eventloop.go:178
Extra = { media_blocks, media_type, input_source, output_channel }
process.go:41 读 Extra["media_blocks"].([]ContentBlock)
distill.go:472 写 Extra["output_channel"]
而 ContentBlock 自身全是可偏移化的:
ContentBlock{ Type, Text string; ImageURL *ImageURL; AudioURL *AudioURL }
ImageURL{ URL, Detail string } AudioURL{ URL string }
没有任何 interface{}、函数或 Go 特有引用类型。 那两个指针只表达"可选",
用 has 标志位 + 内联结构替代。Extra 的 interface{} 是形式上的动态类型,
实际是封闭可判别的联合。
决策:4 个键提升为共享段具名字段,Extra 本身保留 RPC 副本语义。
理由:这 4 个键都是内核写、插件读,无并发改写需求;真正需要多插件并发改的
(LLMText/FinalText/ToolCalls/Errors)全是强类型字段。
不为尚不存在的通用性付 tagged union + 字符串驻留表的成本。
arena 空间管理:append-only。插件把 FinalText 从 10 字节改成 10KB 时
分配新区域、更新描述符、旧区域留作垃圾;arena 用尽由内核在 stage 结束后
(此时无插件持锁)整体压实。代价是单次 stage 内写入总量有上限。
3.4 SDK 必须封装全部复杂度
插件作者永远不接触 Slice{off,len},代码与今天完全一致:
func (p *Plugin) onBeforeToolcall(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
ctx.FinalText = strings.TrimSpace(ctx.FinalText)
return nil
}
SDK 内部承担:mmap 挂载、跨进程锁初始化、arena 分配、偏移↔Go 值转换、
脏字段回写、进程崩溃后段清理。
实现手法:插件进程内保留原生 StageContext 结构。
stage 入口从共享段反序列化成本地对象 → handler 照常读写字段 →
Lock/Unlock 映射到跨进程锁 → handler 返回时脏字段写回共享段。
每次转换微秒级,换来 17 个存量外部插件业务代码零改动。这个交换很值。
3.5 必须留在进程内的:回调型资源
"所有数据放共享内存"需要精确化:共享内存放不了函数指针 (地址在各进程不同,代码段布局也不同)。
| 类别 | 载体 |
|---|---|
| 跨进程状态 | 共享内存 |
| 跨进程行为 | RPC 调用回内核 |
Subscribe 返回的退订闭包、Device 的方法集、OutputChan 的接收端——
这些是"行为"不是"数据"。故准确表述为:
所有跨进程传递的状态放共享内存,行为通过 RPC 调用回内核。
3.6 通知面:事件环 + eventfd
设计骨架(采纳):数据先落地 → 再通知 → 内核不等待。满足约束 B。
但单纯的信号量不够——sem_t 只是计数器,没有 payload、顺序、消费游标:
typedef struct {
uint64_t seq; // 全局单调序号
uint32_t type;
Slice payload; // → arena
} EvtSlot;
typedef struct {
_Atomic uint64_t write_seq; // 仅内核写
uint32_t cap; // 2 的幂
EvtSlot slots[];
} EvtRing;
typedef struct { // 每订阅者独立
_Atomic uint64_t read_seq;
_Atomic uint64_t dropped; // 被覆盖丢弃计数
uint32_t type_mask;
uint64_t last_seen; // 活性判断
} Subscriber;
内核:写 slot → write_seq++ → 对匹配订阅者 post,不等待。
消费者:read_seq 追 write_seq,落后超 cap 即溢出,差值记入 dropped
并跳到最新——允许丢事件但让消费者知道丢了(与 WebUI 侧 sync_required 思路一致)。
技术修正:用 eventfd 而非 sem_t。
Go 里没有轻量线程。goroutine 经 cgo 调 sem_wait 会阻塞整个 OS 线程(M 被占住),
每插件常驻一个锁死线程——又回到我们正要逃离的线程膨胀。
efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
f := os.NewFile(uintptr(efd), "evtnotify")
// eventfd 是 epoll-able,os.NewFile 注册进 runtime netpoller
// f.Read() 阻塞时只 park goroutine,不占 OS 线程
附带收益:
- 计数语义(读出累积值)天然合并突发通知——1000 个 token 事件可能只唤醒几次
- 可与 RPC 请求在同一 select 中等待,不需两套等待机制
✅ 已实测通过(见 7.2):200 个 goroutine 阻塞在 eventfd.Read 上仅增 1 个 OS 线程。
3.7 跨进程锁:倾向"锁仲裁回归内核"
pthread_mutex 的 PTHREAD_PROCESS_SHARED + ROBUST 属性 Go 标准库无等价物。
但引入它意味着为了一个锁而保留 cgo——与"C 整体退场"的目标冲突。
方案对比:
| robust pthread_mutex | 锁仲裁回内核 | |
|---|---|---|
| cgo | 需要 | 不需要 |
| 崩溃处理 | 需处理 EOWNERDEAD + consistent |
进程死了内核直接释放 |
| 加锁成本 | 原子操作(纳秒) | 一次 RPC 往返(微秒) |
| 复杂度 | 高 | 低 |
已裁定采用后者(实验 3+9,见 7.4/7.10):插件通过 RPC 请求"给我 stage 写锁",内核用普通 sync.Mutex 排队。
stage handler 加锁频率很低(每次 stage 一两次,非每字段一次),微秒级往返可忽略。
这样整个新架构可做到完全无 cgo。
注:事件环无需 robust 语义——eventfd/信号量没有所有权概念,
不存在"持锁进程死了"的死锁风险。共享内存中真正需要互斥的只有 StageContext。
3.8 能力对齐:外部插件可获得什么
| 内置独有能力 | 子进程下的等价物 | 可行 |
|---|---|---|
OutputChan 消费 |
共享内存 ring + eventfd 通知 | ✅ |
Subscribe/Publish |
事件环 + 独立游标 | ✅ |
RegisterChannel(Device) |
声明式注册(caps + 工具名清单)+ 调用回传 | ✅ |
SetToolBlocks |
二进制落 arena,Slice 描述符回传 |
✅ |
Config/Tool/Indexer |
新增 RPC method(本就是数据操作) | ✅ |
Selftest/Supervisor/Tracker |
不提供 | ❌ 刻意 |
最后一行是显式的权限决策,而非技术限制——这正是迁移要达成的区分。
副产品:localuse 这类插件不再必须编进内核,改一行不用重编整个 homed。
四、工作量评估
4.1 分阶段拆解
规模标记:S = 1 人日内,M = 2-4 人日,L = 1-2 周,XL = 2 周以上。 风险标记基于「失败时能否安全回退」。
阶段 0:止血(不依赖任何新架构)
⚠️ 本表编号已废弃(此处仅存档当时的分组思路)。 实施请用
plan.md第 11 节 的11.10.7 → plan 11.3/11.1/11.2/11.4。11.6——见 0.3 的对应表。 本表的 0.1/0.2/0.3 → plan 11.6;后文追加的 0.4
| # | 任务 | 文件 | 规模 | 风险 |
|---|---|---|---|---|
| 0.1 | ELF 检测 DF_1_NODELETE,命中则标记插件"不可热重载" |
dynamic_loader_unix.go |
S | 极低 |
| 0.2 | ReloadOne 对此类插件直接返回"需重启",停止假装成功 |
registry.go |
S | 极低 |
| 0.3 | plugin_install 返回 restart_required 替代误导性的 reload_required |
pluginmgr/plugin.go |
S | 极低 |
价值:零运行时开销,立刻消除"模型照着 reload_required 建议重载、实际白跑"的误导。
后续追加(第八、九章发现,优先级高于 0.1-0.3):
| # | 任务 | 现网影响 | 规模 |
|---|---|---|---|
| 0.4 | stageContextWritable 只回传变更字段 |
❗ 脏数据进 LLM,量级百分之几(8.6) | S |
| 0.5 | output_send 改同步等真实结果 |
❗ 消息发不出而模型以为成功(9.4) | M |
| 0.6 | 超时日志措辞修正 + 排查 browser 频繁超时 | ❗ 已泄漏 26 次(9.3) | S |
| 0.7 | Lua stage 快照加 sc.RLock() |
潜在 DATA RACE(9.1) | S |
阶段 1:能力对齐验证(不依赖子进程)
| # | 任务 | 规模 | 风险 |
|---|---|---|---|
| 1.1 | 给 C ABI 补 SetToolBlocks(method id 52,走文件路径传递) |
M | 低 |
| 1.2 | 用某外部插件验证多模态注入端到端可用 | S | 低 |
价值:先验证"外部插件能否逼近内置能力"这一假设,不依赖任何共享内存基础设施。 若此步就发现能力对齐有本质障碍,整个迁移的收益需重估。
阶段 2:子进程通道原型(核心风险点)
| # | 任务 | 文件 | 规模 | 风险 |
|---|---|---|---|---|
| 2.1 | 进程管理器:spawn/健康检查/优雅停止/崩溃重启。可大幅参考 clawhubadapter/sidecarProcess(已有 stdin/stdout + 异步 reader + pending map[int]chan + notifyCh 的成熟实现) |
internal/plugin/proc/(新建) |
L | 中 |
| 2.2 | 双向 JSON-RPC 编解码:7 个 kernel→plugin 调用 + 51 个 plugin→kernel 回调 | 同上 | M | 低 |
| 2.3 | procPlugin 实现 sdk.Plugin 接口,Close() 变成真 kill+wait |
dynamic_loader_unix.go 旁 |
M | 中 |
| 2.4 | loadOne 按 manifest entry 分派:plugin.so→cabi,plugin.bin→proc |
registry.go |
S | 低 |
| 2.5 | plugin_health 接线:进程退出码/EOF → recordCrash(逻辑复用,仅换信号源) |
plugin_health.go 调用侧 |
S | 低 |
| 2.6 | plugindev 新增 tmplProcMain:bridge 从 c-shared 导出改为 main() + stdio loop |
templates.go |
M | 低 |
| 2.7 | plugindev 构建改普通 go build(去 cgo,交叉编译反而简化) |
cmd_build.go |
S | 低 |
| 2.8 | validBinaries 加 plugin.bin,.hmap 打包/校验支持 |
pluginmgr + manifest |
S | 低 |
| 2.9 | 单插件单工具端到端打通(建议用 weather) |
— | M | — |
关键收益:公开 SDK 的 PluginSDK 方法签名全部保留,底层从 callString(id,...)
换成 RPC 发送——17 个存量插件业务代码零改动,只需用新 plugindev 重编。
阶段 3:共享内存数据面
| # | 任务 | 规模 | 风险 |
|---|---|---|---|
| 3.1 | 共享段 schema + arena 分配器(append-only + 压实) | L | 中 |
| 3.2 | StageContext 偏移化编解码(共享段 ↔ 本地 Go 对象) |
L | 中 |
| 3.3 | 锁仲裁 RPC(stage.lock/stage.unlock,内核侧 sync.Mutex) |
M | 中 |
| 3.4 | RunStage 跨进程并发扇出改造(保留并发语义,最难的一环) |
L | 高 |
| 3.5 | 段生命周期:创建/挂载/插件崩溃后清理 | M | 中 |
3.4 是全项目最高风险点:必须保证多插件并发改写同一 StageContext 的语义
与今天一致,否则 sanitizer、multimodal 这类改写型插件行为会静默漂移。
阶段 4:通知面
| # | 任务 | 规模 | 风险 |
|---|---|---|---|
| 4.1 | EvtRing + Subscriber schema,溢出计数 |
M | 低 |
| 4.2 | eventfd 通知 + Go 侧 netpoller 消费(先做 3.6 的实测验证) | M | 中 |
| 4.3 | Bus.Publish 加事件环投递(post-and-forget,不得阻塞) |
S | 高 |
| 4.4 | 订阅者活性检测(last_seen 超时 → recordCrash) |
S | 低 |
| 4.5 | 实现 case 23/24(今日空实现),外部插件首次获得事件能力 |
M | 低 |
4.3 风险高:Bus.Publish 在流式路径上逐 token 调用,任何阻塞都会导致
输出卡顿。改动必须严格无锁/非阻塞,且需专门的流式压测验证。
阶段 5:迁移与收尾
| # | 任务 | 规模 | 风险 |
|---|---|---|---|
| 5.1 | 17 个外部插件逐个重编译 + 回归验证 | L | 中 |
| 5.2 | 删除 cabi/(1096 行)与 bridge 模板(385 行) |
S | 低 |
| 5.3 | 权限梯度显式化:声明式 caps + 内核侧强制 | M | 中 |
| 5.4 | 文档:插件开发指南更新、迁移说明 | M | 低 |
4.2 总量估算
| 阶段 | 规模合计 | 可独立交付 |
|---|---|---|
| 0 止血 | ~1 人日 | ✅ 立即 |
| 1 能力对齐 | ~3 人日 | ✅ 独立 |
| 2 子进程通道 | ~3 周 | ✅ 与 cabi 共存 |
| 3 共享内存 | ~3 周 | ⚠️ 依赖阶段 2 |
| 4 通知面 | ~1.5 周 | ⚠️ 依赖阶段 3 |
| 5 迁移收尾 | ~2 周 | ⚠️ 依赖全部 |
合计约 10 周(单人、含验证,不含意外)。
实验后修订:约 8-9 周(见 7.15)。第九章新增的 Lua/Windows 路径收敛 已包含在阶段 5 的迁移工作内,不额外增加工期——因为它们是删除而非改造。
4.3 成本对照
作为决策参考,三条路的真实成本:
| 方案 | 一次性成本 | 长期代价 | 性质 |
|---|---|---|---|
| 接受重启(仅做阶段 0) | ~1 人日 | 每次换 .so 中断 ~15 秒 |
有界、自愈 |
| 版本化路径 | ~3 人日 | 每次重载 +5.8 线程 +1.5MB,永久 | 无界、单调劣化 |
| 子进程 + 共享内存 | ~8-9 周 | 常驻 +29MB RSS(7.6 实测,原估 50-70MB 偏高) | 有界、换来真隔离 |
插件更新的真实频率是每周级(今日的密集调试是特例)。 若唯一目标是热重载,阶段 0 的性价比远高于全量迁移。
迁移正当性共 6 条(与 9.5 同一份清单;本节讨论的热重载是第 ① 条):
| # | 正当性 | 依据 | 有临时修复? |
|---|---|---|---|
| ① | 热重载 | 1.1(原始动机) | ✅ 11.6 可缓解(改为诚实上报) |
| ② | 插件崩溃隔离(现在一个插件 panic 能带崩 homed) | 实验 6 | ❌ 无 |
| ③ | 能力断层消除(外部插件获得事件订阅、多模态、通道注册) | 1.3 / 3.8 | ❌ 无 |
| ④ | 内置插件解耦(localuse 改一行不用重编 homed) |
1.3 | ❌ 无 |
| ⑤ | 修复 stage 副本 lost update(实测 35.8~36.8%,现网量级百分之几) | 8.4 / 8.6 | ⚠️ 11.3 打补丁 |
| ⑥ | 修复 cgo 固有缺陷:超时泄漏(现网 26 次)、output_send 假成功(现网 2 次)、Windows 退化、三套 ABI 分裂 |
第九章 | ⚠️ 11.1/11.2/11.5 打补丁 |
关键判断:①⑤⑥ 有临时修复(plan.md 11.1~11.6),不必等迁移;
但那些修复是在副本模型内部打补丁,只有子进程 + 共享内存才从根上消除成因。
②③④ 无临时方案——它们是迁移的不可替代价值。
若这六点都不成立,则不应迁移。
4.4 风险登记
| 风险 | 影响 | 缓解 |
|---|---|---|
RunStage 并发语义漂移(3.4) |
改写型插件行为静默错误 | 机制已验证(7.9);仍需为 sanitizer/multimodal 补并发行为测试作为基线 |
Bus.Publish 引入阻塞(4.3) |
流式输出卡顿 | 专项流式压测;投递路径禁用任何锁 |
| — | ✅ 已排除(7.2 实测 +1 线程) | |
| 17 进程常驻开销 | 实测仅 +29MB RSS(7.6) | ✅ 风险关闭 |
| arena 单 stage 写入上限 | 大写入插件失败 | 明确上限并在 SDK 层报错,而非静默截断 |
| Windows 无验证环境(9.2) | 迁移后 Windows 行为未知 | 需借测试机;当前 Windows 路径本就严重退化,风险不增 |
| Lua 插件迁移路径未设计(9.1) | Lua 插件如何跑在子进程内 | 可保留进程内 Lua VM(无 cgo 问题)或独立 Lua 宿主进程;待设计 |
| 存量插件回归 | 17 个插件行为变化 | 阶段 2.4 的 entry 分派让两种插件共存,可逐个迁移、随时回退 |
4.5 推进原则
双通道共存是整个计划可行的前提。 registry.go 按 manifest entry 分派
(阶段 2.4)意味着 .so 与 .bin 插件可同时运行:
1. 打通 proc 通道,用 weather 验证
2. 逐个迁移,其余 .so 继续跑
3. 全部迁完再删 cabi 路径
任何一步失败都能回退,不会出现"改到一半 homed 起不来"。
五、待定决策
⚠️ 本章为第七章实验前的初始状态。最新裁定见 7.14,正当性清单更新见 9.5。
以下需明确后才能进入实施:
-
是否全量迁移? ⏳ 仍待决定。若只为热重载,阶段 0 即够(1 人日 vs 8-9 周)。 全量迁移的理由已从 3 条扩充到 6 条(完整对照表见 4.3)。 其中 ②崩溃隔离 / ③能力对齐 / ④内置解耦 无临时替代方案; ①热重载 / ⑤lost update / ⑥cgo 缺陷 可先用
plan.md11.1~11.6 打补丁。 -
跨进程锁选型:
锁仲裁回内核 vs robust pthread_mutex✅ 已裁定:锁仲裁回内核(实验 3 测得 19.4 µs/次;实验 9 证明持锁进程崩溃可自愈, 无需EOWNERDEAD处理)。新架构完全无 cgo。 -
Extra处置:✅ 维持原建议——4 键提升为共享段具名字段,Extra本身留 RPC 副本。 第八章的发现进一步支持此选择:外部插件根本拿不到Extra(不在下发的 10 个字段内), 故通用 tagged union 是为不存在的需求付成本。 -
权限梯度的显式形式:⏳ 待设计(不阻塞阶段 0-2)。 前提已澄清:内置插件的高权限是刻意的设计决策,不是"自己人所以安全"。 当前实现把「应有的权限梯度」与「C ABI 的表达能力天花板」混在了一起—— 外部插件拿不到
OutputChan是技术限制(Go channel 过不了 C ABI), 而非权限决定(证据:case 23/24事件订阅是空实现,是"给不了"而非"不给")。 迁移目标是让梯度从技术意外变成显式声明并强制的策略,而非消除梯度。Selftest/Supervisor/Tracker确认永不对外(见 3.8 能力对齐表最后一行)。
六、已验证事实清单
本文档结论的实证基础,便于后续复核:
| 结论 | 验证方式 | 结果 |
|---|---|---|
Go c-shared 带 DF_1_NODELETE |
readelf -d plugin.so |
FLAGS_1: NODELETE |
dlclose 对其是 no-op |
/proc/self/maps 段数 |
5 → 5(不归零) |
纯 C .so 可真正卸载 |
同上 | 5 → 0,换代码生效 |
| C shim 中间层无法绕过 | 经 shim dlopen/dlclose Go 库 | 5 → 5,仍未卸载 |
| Go c-shared 无 SONAME | readelf -d | grep SONAME |
无(换路径不会被去重) |
| 版本化路径有效 | 两个内容不同的 Go c-shared | handle 不同,ver=v2 生效 |
| 每次重载泄漏 ~5.8 线程 | 30 次连续 dlopen,读 /proc/self/task |
+168 线程 / +46MB RSS |
GOMAXPROCS=1 缓解有限 |
同上 | 降至 +4.0 线程/次 |
| stage 是并发扇出 | 读 stages.go:124 |
go func + wg.Wait |
Bus.Publish 同步阻塞 |
读 bus.go:56 |
内联 safeCall 循环 |
| 流式逐 token 发事件 | process.go:388/470/479 |
EventContentDelta |
| 外部插件无事件能力 | 读 loader.go case 23/24 |
空实现 return 0 |
Extra 仅 4 个键 |
全量 grep 使用点 | eventloop/process/distill 各处 |
| toolcall payload 很小 | 96 样本统计 | 中位 93 B,最大 134 B |
| 内置/外部方法数 | 对比两个 PluginSDK |
28 vs 36,差在活对象 |
| 生命周期机制已完善 | 读 plugin_health.go/toolcall.go |
3 崩溃/5 分钟、60 秒超时、30 秒冷却 |
第七章新增(新架构可行性,11 项)
| 结论 | 验证方式 | 结果 |
|---|---|---|
| eventfd 走 netpoller | 200 goroutine 阻塞 Read,读 /proc/self/task |
+1 线程 |
| 跨进程偏移解引用 | 父子进程 mmap 基址对比 | 基址不同,偏移仍正确 |
| memfd + fd 继承可建共享段 | MemfdCreate + ExtraFiles |
无需 /dev/shm 命名与清理 |
| 锁仲裁 RPC 成本 | 20000 次 stdio 往返 | 19.4 µs/次 |
| post-and-forget 解耦流式 | 5000 token + 20µs 慢消费者 | 5.07s → 2.29ms(2218x) |
| 17 子进程常驻开销 | 读 smaps_rollup PSS |
29.1MB RSS / 12.9MB PSS |
| 子进程线程数低于单进程 | 对照 homed | 84 vs 108 |
| 崩溃隔离 | 子进程 panic | 退出码 2,EOF 2.5ms,宿主存活 |
| 子进程热重载 | 同路径替换二进制 | v1→v2 立即生效 |
| 跨进程并发改写 StageContext | 5 进程 × 300 轮 append | 358 字符 = 358 长度,零丢失 |
| 持锁进程崩溃自愈 | 持锁 panic 后其他进程申请 | 正常获得,无死锁 |
| 二进制零拷贝 | 100KB/1MB/5MB 对比 | 18-22x,体积 −100% |
| 工具调用 RPC 延迟 | 10000 次真实 payload | p50 19.5 µs |
第八章新增(现有实现的真实语义)
| 结论 | 验证方式 | 结果 |
|---|---|---|
| 外部插件 stage 是副本模型 | 读 loader.go:411-439 + templates.go:768-787 |
快照→新对象→写回 |
外部插件 ctx.Lock() 是空操作 |
同上链路推演 | 锁的是副本自己的 mu |
| 外部插件字段被裁剪 | 对比下发字段与 StageContext |
16 字段只下发 10 |
| 副本模型 lost update 率 | 复刻链路,5 插件 × 2000 轮 | ❗ 36.8%(内置 0%) |
stageContextWritable 无条件回传 |
读 templates.go:762 |
非空即回传,未改也传 |
| 现网 sanitizer+weather 冲突 | 复刻场景 3000 轮 | ❗ 1.6~4.3% 脏数据进 LLM |
| 现网两插件均在运行 | plugin.json + disabled_plugins + 日志 |
均 8/15 部署,未禁用 |
StageScopeOwnTools 不减并发 |
读 sdk/plugin.go:304 |
SDK 层包装,仍并发调度 |
第九章新增(补盲)
| 结论 | 验证方式 | 结果 |
|---|---|---|
| 插件类型有四种 | validBinaries + internal/plugin/*.go |
so/dylib/dll + lua + SKILL.md |
| Lua stage 快照无读锁 | 读 lua_plugin.go:726 |
无 sc.RLock(),DATA RACE |
| Windows DLL 只下发 3 字段 | 读 dynamic_dll_windows.go:231 |
且完全无写回 |
| cgo 超时不可中断 | 纯 C 死循环 .so,20 次卡死 |
❗ 泄漏 20 goroutine / 18 线程 |
| 子进程 Kill 后零泄漏 | 同实验 B 组 | OS 回收全部资源 |
| 现网已发生工具超时 | 14 天 journal 统计 | ❗ 26 次(browser 占 22) |
output_send 永远返回成功 |
读 loader.go:458-470 + output.go:65-70 |
❗ 返回 {status:queued} |
| 现网已发生发送失败 | 7 天 journal 统计 | ❗ 2 次,模型收到"已发送" |
| homed 主 heap 2.36GB 真实驻留 | /proc/PID/status + maps 分析 |
与插件无关,独立问题 |
七、前期可行性实验(已执行)
本章记录第五章「待定决策」与第四章风险项的实测结论。
所有实验代码位于 /tmp/feas/,环境 go1.25.12 linux/amd64,本机 192.168.2.60。
7.1 结论总览
| # | 待验证项 | 原假设 | 实测结论 |
|---|---|---|---|
| 1 | eventfd 是否走 netpoller | ⚠️ 待实测 | ✅ 成立,200 等待者仅 +1 线程 |
| 2 | 跨进程 eventfd + 偏移解引用 | 推理 | ✅ 成立,不同 mmap 基址正确解引用 |
| 3 | 锁仲裁 RPC 往返成本 | 「微秒级」 | ✅ 19.4 µs/次 |
| 4 | post-and-forget 解耦流式 | 推理 | ✅ 2218x 加速 |
| 5 | 17 子进程常驻开销 | 估 50-70MB | ✅ 实际 29MB RSS / 12.9MB PSS,远优于估计 |
| 6 | 崩溃隔离 + 退出码信号源 | 推理 | ✅ 退出码 2,EOF 2.5ms 感知,宿主存活 |
| 7 | 子进程热重载 | 推理 | ✅ 同路径替换即生效,无需版本化路径 |
| 8 | 跨进程并发改写 StageContext | ⚠️ 最高风险 | ✅ 5 插件 × 300 轮无丢失无撕裂 |
| 9 | 持锁进程崩溃自愈 | 需 robust mutex? | ✅ 不需要,内核 Wait/EOF 强制释放 |
| 10 | 二进制零拷贝收益 | 推理 | ✅ 18-22x 加速,体积省 100% |
| 11 | 工具调用 RPC 延迟 | 「噪声里」 | ✅ p50 19.5 µs,占 LLM 往返 0.00065% |
本章 11 项全部通过(另有第一章 dlclose 组 3 项、第九章 cgo 组 2 项,文档合计 18 项可复跑实验)。 第五章的 4 个待定决策中,2、3 已由实验裁定。
7.2 实验 1:eventfd 走 netpoller(原文档标记 ⚠️ 待实测)
基线线程数: 5 (GOMAXPROCS=12)
200 个 goroutine 阻塞在 eventfd.Read 后:
线程数 = 6 (增长 1)
✅ 走 netpoller:线程未随等待者数量增长
唤醒数 = 200/200
结论:os.NewFile(eventfd) 确实注册进 runtime netpoller,Read 只 park goroutine。
200 个等待者仅增 1 个 OS 线程,验证了 3.6 节的设计前提。
反面对照即 sem_wait:经 cgo 会阻塞整个 M,200 等待者 = 200 锁死线程。
7.3 实验 2:跨进程 eventfd + 偏移解引用
PARENT: mmap 基址 = 0x7f0137a36000
CHILD: mmap 基址 = 0x7f1eb3437000 ← 不同虚拟地址
PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:28, seq:42}
PARENT: post 耗时 10.85µs ← post-and-forget
CHILD: 被 eventfd 唤醒, 计数=1
CHILD: 偏移解引用 off=1024 len=28 seq=42 → "hello-from-parent-via-offset"
PARENT: 读到子进程回写 → "CHILD-ACK" ✅ 双向可见
两个关键点得到验证:
- 父子进程 mmap 到完全不同的虚拟地址(
0x7f0137a36000vs0x7f1eb3437000), 相对偏移{off,len}仍正确解引用——这正是「偏移替代指针」的核心论据 memfd_create+ExtraFilesfd 继承即可建立共享段,无需/dev/shm命名与清理
7.4 实验 3:锁仲裁 RPC 成本(裁定决策 2)
20000 次 stage.lock RPC 往返 用时 388ms, 均摊 19.40 µs/次
裁定:采用「锁仲裁回归内核」,放弃 robust pthread_mutex。
19.4 µs 相对 stage handler 的实际工作量(LLM 往返 2-8 秒)完全可忽略。
换来:零 cgo、无 EOWNERDEAD 处理、崩溃自愈(见 7.10)。
7.5 实验 4:post-and-forget 解耦流式输出
模拟 5000 token 流式发布 + 20µs 慢消费者:
A 同步 Publish (现状): 5000 token 耗时 5.07s 均摊 1014.5 µs/token
B 环+eventfd post: 5000 token 耗时 2.29ms 均摊 0.46 µs/token
加速比 2218.6x 丢弃事件 0
验证了约束 B 的严重性与解法有效性。现状下一个 20µs 的慢订阅者 就能让 5000 token 的流式输出多花 5 秒;改为写环 + post 后降到 2.3ms。
7.6 实验 5:17 子进程常驻开销(修正文档估计)
存活进程 17/17
合计: PSS=12.9 MB RSS=29.1 MB 线程=84
均摊: PSS=0.76 MB RSS=1.71 MB 线程=4.9
插件二进制大小: 2.68 MB
原估计 50-70MB 偏高,实际 29MB RSS / 12.9MB PSS。 PSS 远低于 RSS 说明 Go runtime 的只读代码页在进程间共享了。
意外发现的对照数据:
homed 当前(单进程 + 15 个已映射 .so): RSS=2344 MB 线程=108
线程数 108 高于 17 个独立子进程的 84——因为每个 c-shared 映像
都带自己的 sysmon/GC worker,塞在同一进程里并不省线程。
7.7 实验 6:崩溃隔离
正常调用 → map[ok:true]
发送 boom(插件内 panic)...
调用侧感知: EOF (耗时 2.515ms)
进程退出码 = 2 ← panic 的标准退出码
宿主进程仍存活 ✅ 崩溃已隔离
plugin_health.go 的接线方案得到验证:exec.ExitError.ExitCode() 与
stdio EOF 都能在毫秒级感知,直接喂给现有 recordCrash(plugin) 即可,
3 次/5 分钟窗口、30 秒冷却、pendingReloads 全部逻辑不动。
对照当前 .so 模型:bridge 兜不住的 panic 会带崩整个 homed。
7.8 实验 7:热重载(迁移的原始目标)
1) 首次启动插件 → version = v1.0.0
2) 替换二进制为 v2.0.0(同路径)
3) 重启插件进程 → version = v2.0.0
✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏
第 1.1/1.2 节的全部问题在子进程模型下自动消失:
不需要 .load/plugin-<hash>.so、不需要重载配额、不需要 ELF 标记检测。
7.9 实验 8:跨进程并发改写 StageContext(最高风险点 3.4)
5 个独立进程各 300 轮,通过 RPC 申请内核侧锁,在共享段 append-only arena
上读-改-写同一个 final_text:
最终 final_text 长度 = 358
各插件写入次数: map[A:76 B:70 C:73 D:73 E:66]
总字符 = 358, 长度 = 358 → 一致 ✅ 无丢失/无撕裂
RPC 锁操作 = 726 次, 总耗时 213ms
总字符数严格等于最终长度,证明:
- 没有写丢失(lost update)
- 没有撕裂读(torn read)
- 5 个进程的修改都被保留且顺序一致
写入次数少于 5×300 是 arena 64KB 上限所致(append-only 未实现压实), 符合 3.3 节设计——印证了 arena 需要压实机制,且上限应在 SDK 层显式报错。
风险 3.4 的核心机制得到验证,但仍需注意:本实验验证的是机制正确性,
不能替代 sanitizer/multimodal 的行为回归测试(4.4 节风险登记仍然有效)。
7.10 实验 9:持锁进程崩溃自愈(裁定决策 2 的第二半)
1) 插件 X 拿锁后 panic:
[X] 获得锁
[X] 进程死亡(exit status 1),内核强制释放其持有的锁 ← 自愈
2) 插件 Y 随后申请同一把锁:
[Y] 获得锁
[Y] 释放锁
✅ Y 正常获得并释放锁 —— 无死锁
这条彻底排除了 robust pthread_mutex 的必要性:
锁的所有权在内核进程,插件死亡由 cmd.Wait() / stdio EOF 检测,
内核代为释放。不存在「持锁者死亡导致全局死锁」的场景。
故 3.7 节的选型确定:锁仲裁回归内核,整个架构零 cgo。
7.11 实验 10:二进制零拷贝(未来多媒体能力)
| payload | JSON+base64 | 共享内存 | 加速 | 体积 |
|---|---|---|---|---|
| 100KB | 1.21 ms,136587 B (+33%) | 62.7 µs,8 B | 19x | −100% |
| 1MB | 12.41 ms,1398155 B (+33%) | 554.7 µs,8 B | 22x | −100% |
| 5MB | 49.30 ms,6990559 B (+33%) | 2.72 ms,8 B | 18x | −100% |
共享内存对二进制 payload 的价值确认:传输体积从 +33% 降为 8 字节描述符, 处理耗时降低约 20 倍。这是 2.5 节「共享内存价值不在省序列化」的唯一例外—— 对大块二进制它恰恰就是省序列化。
7.12 实验 11:工具调用 RPC 延迟
用实测的真实 payload 形态({"city":"hangzhou","days":3,...},约 93 B)10000 次:
p50 = 19.497µs p90 = 25.447µs p99 = 44.603µs max = 2.256ms
对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ 0.00065%
验证 2.5 节判断:控制面用 JSON-RPC 完全够用,无需为它引入共享内存。
7.13 意外发现:homed 当前内存异常(独立问题)
实验 5 的对照测量暴露了一个与迁移无关但值得记录的问题:
homed RSS = 2390432 kB (2.34 GB)
RssAnon = 2354764 kB ← 真实驻留的匿名内存
RssFile = 35668 kB
VmSize = 26016540 kB (24.8 GB 虚拟)
匿名映射构成:
2420.0 MB × 1 = 2.36 GB ← 主 homed 的 Go heap(真实驻留)
512.0 MB × 15 = 7.50 GB ← 15 个插件各自的 heap arena(虚拟预留)
两点观察:
-
512MB × 15:每个 Go c-shared 插件各自 mmap 独立 heap arena, 彼此不可见、GC 各自为政。这是虚拟预留(不占物理内存), 但说明当前架构下插件间内存无法协同回收——子进程模型下反而更清晰。
-
2.36 GB 真实驻留在主 homed 的 heap 上,与插件无关。 这是独立的内存增长问题(可能是 chat history / context 累积), 不影响迁移评估,但应单独排查。
7.14 实验后更新的决策状态
第五章 4 个待定决策的当前状态:
| # | 决策 | 状态 |
|---|---|---|
| 1 | 是否全量迁移 | ⏳ 待用户决定(技术可行性已全部验证) |
| 2 | 跨进程锁选型 | ✅ 已裁定:锁仲裁回内核,零 cgo(实验 3+9) |
| 3 | Extra 处置 |
✅ 维持原建议:4 键提升为具名字段(实验 2 验证偏移化可行) |
| 4 | 权限梯度显式形式 | ⏳ 待设计(不阻塞阶段 0-2) |
7.15 工作量评估的修订
实验结果对第四章的影响:
| 项 | 原评估 | 修订 |
|---|---|---|
| 3.7 跨进程锁 | 两方案待选,可能需 cgo | 确定零 cgo,规模 M→S |
| 4.2 eventfd 消费 | ⚠️ 待实测,风险中 | 验证通过,风险中→低 |
| 3.4 并发扇出 | 风险高 | 机制已验证,风险高→中(行为回归仍需做) |
| 常驻开销 | 估 50-70MB | 实际 29MB,风险项可关闭 |
| 4.3 Publish 改造 | 风险高 | 收益已量化(2218x),风险高→中 |
总量估计从约 10 周下调至约 8-9 周(3.7 简化 + 3.4/4.2 风险降低)。
不变的部分:阶段 5 的 17 个插件逐个回归验证仍是 ~2 周,无法压缩。
八、代码检查与实地实验(第二轮)
第七章验证的是新架构可行性;本章检查现有实现的真实语义, 并发现了一个先前评估建立在错误前提上的关键事实。
8.1 核心更正:外部插件从未共享过 StageContext
第 2.4 节把「stage 并发扇出改写同一对象」列为约束 A,并据此论证共享内存的必要性。 代码检查表明:该语义只对内置插件成立,外部插件一直是「快照-副本-写回」模型。
完整链路(cabi/loader.go:411-439 + templates.go:768-787):
① 内核 case 2 handler
sc.RLock() → 快照 7~10 个字段为 JSON → sc.RUnlock()
② 跨 ABI 传字符串
③ go_invoke_stage
sc := &sdk.StageContext{} ← 插件进程内【全新对象】
fillStageContext(sc, ctxJSON)
④ 插件 handler 执行
ctx.Lock() 锁的是这个新对象的 mu ← 无竞争者,纯空转
⑤ stageContextWritable(sc) → Marshal 回传
⑥ applyStageResult(sc, result)
sc.Lock() → 逐字段写回内核 sc → sc.Unlock()
这是为方便插件加载而采取的无奈之举(C ABI 无法传递 Go 对象引用), 但它带来三个先前未被识别的后果。
8.2 后果一:外部插件的 ctx.Lock() 是空操作
sanitizer 的 stage handler(example/sanitizer/plugin.go:52-90):
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
ctx.Lock() // ← 锁的是副本自己的 mu
defer ctx.Unlock() // 插件进程内无其他 goroutine 竞争
for i, tr := range ctx.ToolResults { ... }
})
插件作者按文档正确加锁,但该锁不提供任何跨插件互斥。 锁语义在 ABI 边界上静默失效——插件作者无从察觉。
8.3 后果二:字段可见性被静默裁剪
内核只快照 7 个字段 + 3 个条件字段(loader.go:412-428):
raw_message user_id group_id phase llm_text final_text no_memory
+ response(非 nil) tool_calls(非空) tool_results(非空)
StageContext 实际有 16 个字段。外部插件永远看不到:
ContextMsgs ReasoningContent TokenUsage Memory Extra Errors
这解释了 3.3 节的一个疑问——Extra 只有 4 个键且全由内核读写,
因为外部插件根本拿不到它。
8.4 后果三(严重):副本模型存在真实的 lost update
read-modify-write 在「快照 → 副本修改 → 写回」链路上非原子。
快照与写回之间的窗口使并发 handler 互相覆盖。
实验 12(/tmp/lostupdate/main.go,精确复刻上述链路,5 插件并发追加标记 × 2000 轮):
内置插件(共享同一对象) 0/2000 轮出现修改丢失 (0.0%)
外部插件(快照-副本-写回) 735/2000 轮出现修改丢失 (36.8%)
内置模型零丢失,外部副本模型丢失率 36.8%。
8.5 现网影响面核查
各 stage 的实际注册者:
| Stage | 注册者 | 风险 |
|---|---|---|
PreAction |
memo(外部) + webui(内置) | ⚡ 外部写回可能覆盖内置修改 |
BeforeToolcall |
qq(外部/own_tools) + webui(内置) + cmd(内置) | ⚡ 同上 |
AfterToolcall |
sanitizer(外部/Global) + weather(外部/own_tools) | ⚠️ 两个外部插件同 stage |
OnInput |
sanitizer(外部) | — |
PostAction |
sanitizer(外部) | — |
BeforeOutput |
webui(内置) | — |
关键点在 stageContextWritable(templates.go:762):
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults // ← 无条件回传
}
只要 ToolResults 非空就回传——即使插件根本没修改它。
weather 的 handler 只做只读打印,但仍会把它收到的快照版本写回内核。
8.6 实验 13:现网场景复刻(确认脏数据进 LLM)
精确复刻「模型调用 weather_query 时 sanitizer + weather 并发跑 AfterToolcall」
(/tmp/lostupdate/real.go,3000 轮):
3000 轮中 47 轮清洗结果被覆盖 (1.6%)
⚠️ weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果
→ 脏数据(ANSI 转义)进入 LLM 上下文
⚠️ 该比率随机器负载波动:复跑观测到 1.6% ~ 4.3% 区间 (取决于两个插件 handler 的实际执行耗时比)。 应理解为「量级在百分之几」而非精确常数。
现网条件已确认:
sanitizer v0.1.0 entry=plugin.so 已部署 2026-08-15 未禁用
weather v1.0.0 entry=plugin.so 已部署 2026-08-15 未禁用
运行日志: [sanitizer] stage OnInput/AfterToolcall/PostAction registered
disabled_plugins: 无
这是一个现存的、可复现的、正在生产环境发生的数据污染缺陷, 概率量级为百分之几(复跑区间 1.6~4.3%,取决于两个插件的实际执行耗时比)。
8.7 对迁移论证的影响
先前的论证方向被推翻,但结论被强化。
| 先前认识 | 实际情况 | |
|---|---|---|
| 共享内存的作用 | 保持现有并发协作语义 | 修复副本模型的 lost update |
| 风险 3.4 的性质 | 高风险:可能破坏正确行为 | 中风险:当前行为本就是错的 |
| 迁移的正当性 | 热重载 + 崩溃隔离 + 能力对齐 | 再加一条:修复现存数据污染 |
原先担心「跨进程改造会让 sanitizer/multimodal 行为漂移」——
实际上外部插件早已在副本模型下运行,漂移已经发生了。
共享内存 + 内核锁仲裁(实验 8 验证零丢失)是修复而非风险。
8.8 派生结论:一个可立即修复的缺陷
8.5 的根因(stageContextWritable 无条件回传未修改字段)不需要等待迁移。
最小修复:在 go_invoke_stage 里记录调用前的字段快照,回传时只带真正变更的字段:
before := stageContextWritable(sc) // 调用 handler 前
if err := h(sc); err != nil { ... }
after := stageContextWritable(sc)
diff := changedFieldsOnly(before, after) // 只回传 diff
这能把 8.6 的污染率降到 0(weather 没改 tool_results,就不回传它),
且不改变任何现有插件的代码。
规模 S(约 0.5 人日),风险低,收益立即可见。 建议加入阶段 0,与三个止血项一并做。
8.9 更新后的阶段 0(编号已废弃,见 0.3)
| # | 任务 | 文件 | 规模 |
|---|---|---|---|
| 0.1 | ELF 检测 DF_1_NODELETE → 标记不可热重载 |
dynamic_loader_unix.go |
S |
| 0.2 | ReloadOne 返回"需重启",停止假装成功 |
registry.go |
S |
| 0.3 | plugin_install 改 restart_required |
pluginmgr/plugin.go |
S |
| 0.4 | stageContextWritable 只回传变更字段(修复 8.6 的数据污染) |
templates.go + 重编全部外部插件 |
S |
⚠️ 0.4(= plan.md 11.3)需重新编译并安装全部 17 个外部插件(bridge 模板变更),
须走 plugindev 正规工具链 + plugin_install 内核接口。
本节编号已废弃,实施请用
plan.md的 11.1~11.6(对应关系见 0.3)。
8.10 第二轮实验汇总
| # | 检查/实验 | 结论 |
|---|---|---|
| 12 | 副本模型 lost update 率 | ❗ 36.8%(内置模型 0%) |
| 13 | 现网 sanitizer+weather 冲突 | ❗ 1.6~4.3% 脏数据进 LLM |
| — | 外部插件 stage 语义 | ❗ 一直是副本,非共享 |
| — | 外部插件 ctx.Lock() |
❗ 空操作,锁语义静默失效 |
| — | 外部插件字段可见性 | ❗ 16 字段中 6 个不可见 |
| — | stageContextWritable |
❗ 无条件回传未修改字段(8.6 根因) |
| — | StageScopeOwnTools 过滤 |
✅ 在 SDK 层包装(sdk/plugin.go:304),不减少并发 |
九、补盲分析(第三轮)
第八章发现外部插件 stage 是副本模型;本章继续排查此前评估完全未覆盖的区域, 新发现 4 类问题,其中 2 项正在生产环境造成实际故障。
9.1 盲区一:插件类型不止两种,Lua 路径存在数据竞争
此前全程只讨论 native(内置)与 cabi(.so)两类。实际有四种加载路径:
internal/plugin/lua_plugin.go 978 行 ← 完全未评估
internal/plugin/dynamic_lua.go 24 行
internal/plugin/dynamic_dll_windows.go ← 完全未评估
internal/plugin/dynamic_loader_unix.go
validBinaries 印证了这一点:
var validBinaries = map[string]bool{
"plugin.so": true, "plugin.dylib": true, "plugin.dll": true,
"main.lua": true, "SKILL.md": true, // ← Lua 与 Skill
}
Lua stage handler 同为副本模型,但缺少读锁保护:
| 路径 | 快照时是否持锁 |
|---|---|
cabi(loader.go:412) |
✅ sc.RLock() → 快照 → sc.RUnlock() |
Lua(lua_plugin.go:726) |
❌ 直接读 sc.RawMessage 等字段,无锁 |
func makeStageHandler(...) sdk.StageHandler {
return func(sc *sdk.StageContext) error {
plg.mu.Lock() // ← 锁的是 Lua VM,不是 sc
defer plg.mu.Unlock()
ctx := map[string]interface{}{
"raw_message": sc.RawMessage, // ← 无 sc.RLock()
"llm_text": sc.LLMText,
...
}
由于 RunStage 是并发扇出,这与其他 handler 的 sc.Lock() 构成数据竞争:
Go race detector 会报 DATA RACE,string header 并发读写理论上可读到撕裂值。
现网影响:当前未部署 Lua 插件(find 无 main.lua),故暂未触发。
但这是一个已存在的缺陷,一旦部署 Lua 插件 + 任意其他 stage 插件即可触发。
9.2 盲区二:Windows DLL 路径能力严重退化
dynamic_dll_windows.go:227-245 的 stage 实现:
ctxJSON, _ := json.Marshal(map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"phase": string(sc.Phase),
})
syscall.SyscallN(p.invokeStage, p.handle, ...)
return nil // ← 无 resultOut,无 applyStageResult
三条路径的字段可见性对比:
| 路径 | 下发字段数 | 写回 |
|---|---|---|
| Linux cabi | 10(7 固定 + 3 条件) | ✅ applyStageResult |
| Lua | 10 | ✅ applyLuaStageResult |
| Windows DLL | 3 | ❌ 完全没有 |
后果:sanitizer 这类改写型插件在 Windows 上静默失效——
handler 正常执行、日志正常打印,但所有修改被丢弃。
且看不到 llm_text/final_text/tool_calls/tool_results,
意味着 Windows 上的外部插件基本无法做任何有意义的 stage 处理。
这是跨平台一致性的严重缺口,且没有任何运行时警告。
9.3 盲区三(严重):cgo 调用不可中断,工具超时永久泄漏
toolcall.go:32-43 的超时保护:
done := make(chan string, 1)
go func() { done <- a.executeToolCallInner(tc) }()
select {
case result := <-done: return result
case <-time.After(60 * time.Second):
return "工具执行超时(60秒),已取消" // ← "已取消"是不准确的
}
select 超时只是让调用方返回,goroutine 仍卡在 C.call_invoke_tool 里。
cgo 调用不可被 Go runtime 抢占或取消——C 函数不返回,该 M(OS 线程)永久占用。
实验 14(/tmp/feas/hang/,纯 C 死循环 .so,20 次卡死调用):
基线 threads=6 goroutines=1
5 次卡死调用后: goroutines= 6 threads= 9 (+3)
10 次卡死调用后: goroutines=11 threads=14 (+8)
15 次卡死调用后: goroutines=16 threads=19 (+13)
20 次卡死调用后: goroutines=21 threads=24 (+18)
结论: 20 次超时 → 泄漏 20 goroutine, 18 OS 线程
线性泄漏,永不回收。 对照子进程模型(同实验 B 组):
cmd.Process.Kill() 后 OS 回收全部资源,零泄漏。
现网已在发生(近 14 天日志):
9 tool browser_screenshot timed out after 60s
5 tool browser_render
2 tool output_send__webui
2 tool browser_type
2 tool browser_start
2 tool browser_click
1 tool cmd_run / browser_html / browser_fetch
─────
26 次超时 → 推算泄漏约 26 goroutine + 20+ OS 线程
browser 插件是主要来源(22/26)。这部分解释了 homed 的 108 线程
——虽然本次运行 9.5 小时内无超时(futex_wait_queue 100 个属正常 Go 调度),
但历史进程(如 homed[1063615]、homed[2609279])在超时后必然累积了泄漏。
这是「60 秒超时保护」的语义谎言:日志说"已取消",实际什么都没取消。
9.4 盲区四(严重):output_send 永远返回成功,模型无法感知发送失败
cabi/loader.go:458-470 的注释直接点明了原因:
s.RegisterOutputChannel(chName, n1, a2, chDef, func(args ...) (interface{}, error) {
// Output is async: return immediately, send in background
// to avoid nested cgo calls (cgo within cgo can crash)
go func() {
if err := pluginInvokeOutput(pid, chName, string(argsJSON)); err != nil {
log.Printf("[dispatch] async output %s/%s failed: %v", ...) // ← 仅日志
}
}()
return map[string]interface{}{"status": "queued"}, nil // ← 立即返回"成功"
})
「cgo 嵌套会崩」这个 C ABI 限制,逼出了 fire-and-forget 设计。
完整链路(output.go:65-70):
模型调用 output_send__qq
→ dev.Execute("output", args) 返回 {status: queued}, err=nil
→ 模型看到: 「已通过 [qq] 通道发送: map[status:queued]」 ← 成功
→ 数十毫秒后 goroutine 里真实发送失败,仅写日志
→ 模型不知道、不重试;用户收不到消息
现网证据(近 7 天):
成功 44 次,失败 2 次
Aug 30 15:10:51 [dispatch] async output qq/qq failed:
invoke_output qq: meta 中需要 group_id 或 user_id 字段
那一次模型收到的是「已发送」,实际消息从未送达。
这与此前的排查直接相关:之前诊断「qq 渠道回复丢失」时修复了系统提示词
(强调 qq 是异步通道、必须用 output_send),但未发现 output_send 本身
永远返回成功。模型即使正确调用了工具,也无法知道是否真的送达。
9.5 子进程模型对这四项的修复能力
| 盲区 | 根因 | 子进程模型 |
|---|---|---|
| 9.1 Lua 无读锁 | 实现疏漏(非架构) | 需单独修;统一走 RPC 后天然有边界 |
| 9.2 Windows 退化 | 三套独立 ABI 实现 | ✅ 单一 RPC 实现,跨平台一致 |
| 9.3 超时不可中断 | cgo 调用不可抢占 | ✅ Process.Kill() 真正取消,零泄漏 |
| 9.4 output 假成功 | cgo 嵌套会崩 | ✅ 可同步等待真实结果 |
9.2/9.3/9.4 都是C ABI 前提的直接产物——三套 ABI 实现、cgo 不可抢占、 cgo 不可嵌套。这三条在进程边界下全部消失。
迁移的正当性清单更新为 6 条(完整版含依据与临时修复对照见 4.3):
- 热重载(原始动机)
- 崩溃隔离
- 能力断层消除
- 内置插件解耦
- 修复 stage 副本模型的 lost update(第八章)
- 修复超时泄漏、output 假成功、Windows 能力退化(本章)
其中 ②③④ 没有临时替代方案,是迁移的不可替代价值;①⑤⑥ 可先打补丁。
9.6 可立即修复项(不依赖迁移)
⚠️ 本表 A-F 编号已废弃,仅存档分组思路。实施请用
plan.md的 11.1~11.6。 对应关系:A→11.1、B→11.3、C→11.4、D→11.2、E→11.5、F→11.6。
按「影响 × 成本」排序:
| # | 修复 | 影响 | 规模 | 备注 |
|---|---|---|---|---|
| A | output_send 改同步等待结果 |
❗ 消除"假成功",模型可重试 | M | 需绕开 cgo 嵌套:用 channel 把结果从 goroutine 传回并等待,而非在 cgo 栈内嵌套调用 |
| B | stageContextWritable 只回传变更字段 |
❗ 消除数据污染(8.6) | S | 需重编 17 插件 |
| C | Lua stage 快照加 sc.RLock() |
消除潜在 DATA RACE | S | 3 行改动 |
| D | 超时日志措辞改为"已放弃等待(插件仍在运行)" | 消除语义谎言 | S | 1 行;真正取消需子进程 |
| E | Windows stage 补齐字段 + 写回 | 跨平台一致 | M | 无 Windows 环境验证 |
| F | 阶段 0 的 0.1/0.2/0.3(ELF 检测等) | 消除 reload 误导 | S | 见 4.1 |
A 是最高优先级:它直接影响用户可感知的行为(消息发不出去而模型以为成功), 且现网已有 2 次实际发生。
⚠️ A 的实现要点:不能简单改成同步调用(会触发 cgo 嵌套崩溃)。 可行做法是保留 goroutine,但用带超时的 channel 等待其结果:
resCh := make(chan error, 1)
go func() { resCh <- pluginInvokeOutput(pid, chName, argsJSON) }()
select {
case err := <-resCh:
if err != nil { return nil, err } // 真实失败上报
return map[string]interface{}{"status": "sent"}, nil
case <-time.After(10 * time.Second):
return map[string]interface{}{"status": "queued", "note": "发送超时未确认"}, nil
}
这样 dev.Execute 的调用栈不在 cgo 内(它由 executeOutputSendTool 从 Go 侧调起),
goroutine 内的 pluginInvokeOutput 才是 cgo 调用——不构成嵌套。
需实测验证不触发崩溃。
9.7 第三轮汇总
| # | 发现 | 严重度 | 现网状态 |
|---|---|---|---|
| 9.1 | Lua stage 快照无 RLock,DATA RACE |
中 | 未触发(无 Lua 插件) |
| 9.2 | Windows DLL 只下发 3 字段且无写回 | 高 | 未验证(无 Windows 部署) |
| 9.3 | cgo 超时不可中断,线性泄漏 goroutine+线程 | 高 | ❗ 已发生 26 次 |
| 9.4 | output_send 永远返回成功 |
高 | ❗ 已发生 2 次 |
| — | 实验 14:20 次卡死 → 泄漏 20 goroutine/18 线程 | — | 已复现 |
| — | 子进程 Kill 后零泄漏 | — | 已验证 |
十、文档维护说明
10.1 三轮验证的递进关系
| 章 | 目的 | 方法 | 主要产出 |
|---|---|---|---|
| 一~六 | 建立评估框架 | 代码阅读 + 推理 | 目标架构、工作量、待定决策 |
| 七 | 验证新架构可行性 | 11 项独立实验(/tmp/feas/) |
全部通过;裁定锁选型 |
| 八 | 检查现有实现真实语义 | 精读 ABI 链路 + 复刻实验 | ❗ 推翻"约束 A"前提;发现现网污染 |
| 九 | 补盲:此前未覆盖区域 | 遍历全部加载路径 + journal 统计 | ❗ 4 类新缺陷,2 项现网故障 |
方法论教训:第八章推翻了第二章基于代码阅读得出的一个核心前提
("stage 并发扇出改写同一对象"对外部插件不成立)。
读到 RunStage 的并发扇出就推断所有插件共享 StageContext,
漏掉了 ABI 边界会把引用降级为副本。 后续评估应对每条跨边界路径单独追踪,
不能从进程内语义外推。
10.2 前文中已被后续章节修订的表述
以下位置保留了原始表述并加了指向更正的标注,阅读时请以后者为准:
| 位置 | 原表述 | 更正 |
|---|---|---|
| 2.4 约束 A | stage 并发改写同一对象 | 仅对内置插件成立(第八章) |
| 2.2 代码规模表 | 只列 native + cabi | 实际四种加载路径(9.1/9.2) |
| 4.1 阶段 0 | 3 项(仅 reload 语义) | 7 项,新增 4 项优先级更高(9.6) |
| 4.2 总量 | 约 10 周 | 约 8-9 周(7.15) |
| 4.3 正当性 | 3 条 | 6 条(4.3 与 9.5 已统一口径) |
| 4.4 风险表 | eventfd 待实测、开销 50-70MB | 已排除 / 实测 29MB |
| 五、待定决策 | 4 项全待定 | 2 项已裁定(7.14) |
| 3.6 eventfd | ⚠️ 待实测 | ✅ 已通过(7.2) |
| 3.7 锁选型 | 倾向锁仲裁回内核 | ✅ 已裁定(7.4/7.10) |
10.3 实验代码(已固化入库)
全部 18 项实验已固化到 experiments/plugin-arch/,
带一键复跑脚本,不再依赖 /tmp:
cd docs/zh/experiments/plugin-arch
./run.sh # 全部 18 项,约 3-5 分钟
./run.sh 12 13 # 只跑指定实验
| 目录 | 主题 | 对应章节 |
|---|---|---|
01-dlclose-nodelete/ |
dlclose 对 DF_1_NODELETE 是 no-op(3 项) |
1.1 / 1.2 |
02-feasibility/ |
新架构可行性(11 项) | 第七章 |
03-lost-update/ |
副本模型 lost update(2 项) | 8.4 / 8.6 |
04-cgo-uninterruptible/ |
cgo 调用不可中断(2 项) | 9.3 |
实现要点:源码均带 //go:build ignore(不进主构建),
run.sh 在 mktemp -d 内构建(不污染主仓 go.mod),
需 x/sys 的实验按需拉取(本机走 clash 127.0.0.1:7890)。
复跑验证:2026-08-31 全量跑通,18/18 通过。
⚠️ 部分数字随调度波动,详见 experiments/plugin-arch/README.md 的
「复跑时的注意事项」——其中区分了「允许波动」与「不应变的断言」。
最重要的一条:8.6 的 1.6% 污染率在复跑中观测到 1.6%~4.3% 区间,
应理解为「量级在百分之几」而非精确常数。
10.4 与 plan.md 的分工
- 本文档:论证、实验数据、架构设计、决策依据 —— 回答"为什么"和"怎么设计"
plan.md第 11 节:可勾选的修复项、文件级改动位置、实施顺序 —— 回答"做什么"
修复项的进度只在 plan.md 维护,本文档不重复勾选状态。