Commit Graph

2 Commits

Author SHA1 Message Date
476e1b1296 fix(proc): stage 锁改可重入 + 协调器结束解绑与自愈,解 qq「重复申请 stage 锁」
## 生产现象

本机 homed(10-07 11:28~21:58)93+1 条:

	[stage] before_toolcall handler error: proc: qq.stage.invoke:
	  申请 stage 锁: proc: 插件 qq 重复申请 stage 锁(handler 内不应嵌套加锁)

qq 的 before_toolcall 是它的**权限门**,每次工具调用都跑。
被拒 ⇒ 该 handler 永不执行 ⇒ **权限门失效**。

## 走过的三条错路(记录以免重犯)

❌ 错路一:「内核没释放锁」。模板 handleStageInvoke 的加解锁完整配对
   (unlocked 标志保证只解一次),不会漏解。

❌ 错路二:「同一插件注册了两个 before_toolcall handler」。
   实测线上 qq 是单进程(pid 恒定),且
   `strings plugin.bin | grep beforeOwnToolcall` = 0 ⇒ 只有一份 handler。

❌ 错路三(我自己实现的):把「重入即报错」改成「重入即等待」。
   ★ 同进程重入就是自己等自己 ⇒ 30s 超时死锁,比原报错更糟。
   跑判据时立刻挂 30s 才发现。**重入的正确解是可重入,不是等待。**

## 真正的根因与修法

`stageLock` 是每个协调器新建一个,而 `lockRegistry` 只 bind 从不解绑;
且 Acquire 对同插件重入直接报错。两个缺口叠加:

1. **Acquire 改为可重入**(新增 depth 计数)
   同一插件可在同一 stage 注册多个 handler(内核 RegisterStageFor 用
   append),它们并发扇出各发一次 stage.lock;同一进程内第二个进来时
   已在临界区,本就不该再取互斥量。depth 减到 0 才真正释放
   ——内层释放时若直接放锁,外层还在临界区就会被别人抢进去。
   不同插件的互斥语义完全不变。

2. **endStage 在「最后离开者」分支解绑路由**
   协调器结束时它就不该再被路由到;否则下一轮的 stage.lock 会打到
   上一轮那只可能仍 held 的锁上。

3. **endStage 补泄漏自愈**(releaseIfLeaked)
   插件异常路径(handler panic 被 recover 吞掉、RPC 写失败)会让 defer
   unlock 之前就 return,锁就永久挂着。锁的所有权在内核进程
   (这正是 ForceRelease 存在的理由),那内核就必须能收尾。

## 判据

stage_lock_reentrant_test.go(8 条)+ 改写 lock_test.go 那条旧判据。

★ 旧判据 TestStageLock_ReentrantAcquireRejected 原本要求「重入必须被拒绝」
  ——它在 2026-09 实验 9 里是对的(那时 Acquire 做成等待)。
  生产证明「拒绝」本身是错的,已改名为 ...ReentrantAcquirePassesThrough,
  并在注释里写明为什么改。

★ **变异测试抓到判据两个缺口**(判据不红 = 判据没覆盖,不是实现没问题):
  · 去掉 releaseIfLeaked → 判据不红 ⇒ 补 TestEndStage_ReleasesLeakedLock
  · 内层 Release 直接放锁 → 判据不红 ⇒ 补 TestStageLock_InnerReleaseKeepsOuterLock
    (原来只断言最终状态,bug 在中间状态)

四个变异重做后全红:还原报错 / 去掉解绑 / 去掉自愈 / 破坏 depth。

## 部署验证

- 内核 install -m 0755 原子替换;qq 用 `hmapdev build` 重编 hmap,
  经内核接口 `plugin_install(path=…, overwrite=true)` 装(reinstalled,
  config_kept=true),再 `plgreload` 生效(pid 2643323 新进程)
- 重载后连调 5 次 qq_get_friends:**重复申请 stage 锁 0 条**
  (部署前那份日志 94 条),stage handler error 0 条
- 权限门确认在执行:5 次调用全部返回真实好友数据,非拒绝
- panic/fatal 0、39 插件、graph.db integrity ok
- 快照 /home/newqqagent/memory/pre-qq-rebuild-20261007-233032/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 07:56:51 +08:00
6b47d185ac feat(plugin): entry 双通道分派 + 共享内存 stage 数据面(Part 1 + Part 4 核心)
Part 1 加载分派骨架(迁移可逐插件推进、随时回退的前提):
- dynamic.go: 新增 binEntry/skillEntry 常量 + entryKind 枚举 + classifyEntry/detectEntryKind
  manifest entry 优先级最高(改回 plugin.so 即回退 cabi);无 manifest 时按目录探测,.bin 优先
- registry.go: tryDynamic 按 entry 分派 proc/cabi 双通道;
  entry 声明 .bin 但二进制缺失时报明确错误,不静默回退(否则'已迁移插件跑回旧通道'极难排查)
- registry.go: pluginEntryHash 候选顺序与 detectEntryKind 对齐(.bin 优先),
  否则增量重载会用错文件算 hash
- dynamic_proc_{unix,windows}.go: tryLoadProc 桩位(权限/类型校验已实现,进程管理属 Part 2)

Part 4 共享内存数据面(迁移评估 §3.3/§3.4/§3.7,最关键一环):
- proc/shm.go: 段布局(Header + ShmStageCtx 描述符数组 + append-only arena)
  相对偏移设计——各进程 mmap 到不同虚拟地址仍能正确解引用
  arena 用尽显式报错而非静默截断(§4.4 风险登记);Compact() 回收 append-only 垃圾
- proc/shmcodec.go: StageContext 16 字段跨进程编解码
  字段级描述符消除 lost update:只改 FinalText 的插件不触碰 ToolResults 描述符
  WriteDirty 只写脏字段——只读插件零写入,不可能覆盖他人改写
  Snapshot 存序列化字符串(切片共享底层数组的坑,C ABI 侧修 11.3 时已踩过)
  Extra 4 键提升为具名字段;Response 用标志位表达 nil vs 空串
- proc/lock.go: 锁仲裁回归内核(§3.7 已裁定,零 cgo)
  ForceRelease 实现实验 9 的崩溃自愈——排除 robust pthread_mutex 必要性
  重复加锁显式拒绝(否则死锁 30s);等待超时有补偿 goroutine 防锁泄漏

验证:
- proc 包 16 项测试全绿(含 -race):全字段往返/只读零写回/原地改切片识别/
  现网 sanitizer+weather 场景/5插件×40轮并发零丢失/arena 耗尽报错/压实不破坏字段/
  锁互斥·串扰拒绝·崩溃自愈·临界区串行化
- entry 分派 9 项测试全绿;go build ./... exit 0;接口冻结 git diff sdk/ 为空
2026-09-02 10:41:18 +08:00