Files
HomeAgent/internal/plugin/proc/host.go
JianFeeeee 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

392 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package proc
import (
"fmt"
"log"
"os"
"sync"
"sync/atomic"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Host 持有**被全部子进程插件共享的统一内存区域**,是共享数据面的
// 所有权中心(§3.3/§3.4)。
//
// ❗ 为什么必须共享一块区域:
// 若每个插件各持一块段,则「内核 ctx → 段 → 插件改 → 回读 ctx」退化成
// 副本模型——两个插件各写各的段、各自回读,最后回读者覆盖前者,
// lost update 原样复现(§8.4 实测 35.8~36.8%)。
//
// 统一区域(§13.1):单 memfd 包含 SuperBlock + StageContext + EvtRing
// + Exchange Arena。子进程经 fd 3 mmap 同一 memfd → 同一份物理页,
// 区域头 SuperBlock 告知各 segment 的偏移与大小。
//
// fd 分配:fd 3 = 统一区域,fd 4 = eventfd。
type Host struct {
memfd *os.File
data []byte // 统一区域完整 mmap
unified *unifiedRegion // SuperBlock 解析结果
seg *Segment // StageContext segment(位于 unified ctxData)
shmSize int // 统一区域总大小
// arena 是跨进程共享内存分配器(§13.2 重设计),由内核独占管理:
// 插件通过 RPC 申请/归还,不做任何分配决策。
//
// 分配与回收都在内核进程内进行,一把 mu 即可保证安全,
// 不存在跨进程分配器那种“共享游标被两个进程各自更新”的竞态。
arena *arenaRegion
// nextOwner 给每个插件进程分配一个不透明 owner ID,供 ReclaimOwner 使用。
nextOwner atomic.Uint32
// 事件通知(独立于共享段)
evtfd *os.File // Unix:eventfd/pipe 读端(fd 4)。Windows 为 nil。
evtNotifyFd int // 通知句柄的平台无关标识
evtRing *EvtRing // 内核侧事件环句柄(位于 unified evtData)
evtSubscriber EvtRingSubscriber
locks *lockRegistry
stageMu sync.Mutex
coordMu sync.Mutex
coord *stageCoordinator
sup *Supervisor
}
// NewHost 创建共享段(平台层 allocShm + 布局初始化)。
//
// 段的**传递机制**按平台分开(shmalloc_*.go),但**布局**完全一致:
// - Linux:memfd,经 ExtraFiles 传继承 fd
// - macOS:立即 unlink 的临时文件(无 memfd_create),同样走 fd 继承
// - Windows:命名 FileMapping(无 fd 继承语义),插件按名字打开
//
// 三者共同点:全部插件看到同一份物理页,段内一律用相对偏移而非指针
// (实验 2 已验证各进程 mmap 到不同虚拟地址时偏移解引用仍正确)。
func NewHost() (*Host, error) {
// 统一区域大小:SuperBlock + StageContext + EvtRing + Exchange Arena
//
// +8 是 arena 基址 8 字节对齐的 padding 余量:arenaOff 向上取整可能
// 吃掉最多 4 字节,预留 8 字节保证 arenaCap 不会小于 arenaPublishSize。
unifiedSize := superBlockSize + shmDefaultSize + evtTotalSize + int(arenaPublishSize) + 8
memfd, data, err := allocShm(unifiedSize)
if err != nil {
return nil, err
}
// 初始化 SuperBlock + 两个 segment
ur, err := initUnifiedRegion(data, shmDefaultSize, evtTotalSize)
if err != nil {
freeShm(memfd, data)
return nil, err
}
// 初始化 Exchange Arena(内核独占的变长块分配器)
arena, err := initArena(data, ur.arenaOff, ur.arenaCap)
if err != nil {
freeShm(memfd, data)
return nil, fmt.Errorf("共享内存分配器初始化: %w", err)
}
if used, total := arena.Stats(); used != 0 || total != ur.arenaCap {
freeShm(memfd, data)
return nil, fmt.Errorf("分配器初始化异常:used=%d total=%d", used, total)
}
// 创建 StageContext segment(位于 SuperBlock 之后)
seg, err := NewSegment(ur.ctxData())
if err != nil {
freeShm(memfd, data)
return nil, err
}
// 初始化 EvtRing 子区域头部(magic/version/cap)
evtData := ur.evtData()
putU32(evtData[evtOffMagic:], evtRingMagic)
putU32(evtData[evtOffVersion:], evtRingVersion)
putU32(evtData[evtOffCap:], evtRingCap)
// 创建 EvtRing segment(位于 StageContext 之后)
evtRing, err := NewEvtRing(evtData)
if err != nil {
freeShm(memfd, data)
return nil, fmt.Errorf("事件环初始化: %w", err)
}
evtRing.Init()
// eventfd 独立于共享段,仍为单独 fd
efd, err := evtfdCreate()
if err != nil {
freeShm(memfd, data)
return nil, fmt.Errorf("创建 eventfd: %w", err)
}
return &Host{
sup: NewSupervisor(),
memfd: memfd,
data: data,
unified: ur,
seg: seg,
shmSize: unifiedSize,
arena: arena,
evtfd: evtfdReadFile(efd),
evtNotifyFd: efd,
evtRing: evtRing,
locks: &lockRegistry{},
}, nil
}
// shmDefaultSize 是共享 StageContext 段的大小。
//
// 取 256KB:StageContext 全字段 JSON 化后典型 < 4KB(工具结果中位 93B,§2.5),
// append-only 中间垃圾由 stage 结束时 Compact 回收,256KB 给足余量。
// 全部插件共享一块,总开销恒定,不随插件数增长。
const shmDefaultSize = 256 * 1024
// Close 释放共享段(StageContext + 事件环)。
// Supervisor 返回子进程台账(供 registry 查询/关停)。
func (h *Host) Supervisor() *Supervisor { return h.sup }
func (h *Host) Close() error {
// 先停全部子进程再拆段:插件还持有映射时 unmap,
// 它们下一次访问共享段就是 SIGBUS。
if h.sup != nil {
h.sup.StopAll(0)
}
// ★ 必须在 unmap **之前**退掉事件环订阅。
//
// 那些订阅的 handler 会 ring.WritePush(写共享内存)。若让它们留在
// Bus 上,munmap 之后只要有一条事件经过 Publish,handler 就写已解除
// 映射的内存 ⇒ SIGSEGV。注意 Bus.safeCall 的 recover **捕不到**它
// (runtime 致命错误不是 panic),所以后果是整个 homed 被杀。
//
// 顺序要求:StopAll 之后(不再有新订阅进来)、freeShm 之前。
if c, ok := h.evtSubscriber.(evtCloser); ok && c != nil {
c.Close()
}
var firstErr error
if h.data != nil {
if err := freeShm(h.memfd, h.data); err != nil && firstErr == nil {
firstErr = err
}
h.data, h.memfd, h.unified = nil, nil, nil
}
if h.evtfd != nil {
h.evtfd.Close()
h.evtfd = nil
}
evtfdClose(h.evtNotifyFd)
return firstErr
}
// beginStage 由插件 handler 进入时调用。
//
// 首个进入者:获取 stageMu(独占共享段)→ 把内核 StageContext 写入段。
// 后续进入者:仅递增 inflight。
//
// enter() 在 coordMu 内完成,两个原因:
// 1. 首进者的 WriteAll 未结束前不能让后到者拿到 coord 就去读共享段
// (旧码的后到者 enter 立即返回,可能读到写一半的段)。
// 2. 与 endStage 的摘除互斥,防止后到者挂进一个正在收尾的协调器
// (具体见 endStage 的注释)。
//
// 锁序:stageMu → coordMu。endStage 只解锁 stageMu、不获取,所以无环。
func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) {
h.coordMu.Lock()
if h.coord == nil {
// 首个进入者:独占共享段直到本次 stage 全部插件离开。
// 必须先放 coordMu 再取 stageMu,不能反序。
h.coordMu.Unlock()
h.stageMu.Lock()
h.coordMu.Lock()
if h.coord == nil {
coord := newStageCoordinator(h.seg)
h.coord = coord
if err := coord.enter(sc, true); err != nil {
// 注意:runStage 的 defer endStage(coord) 是在 beginStage
// 返回 err 的检查之后才注册的,所以这条路径上
// endStage 永远不会被调用——stageMu 必须在此自行释放,
// 否则整个 stage 通道永久卡死。
h.coord = nil
h.coordMu.Unlock()
h.stageMu.Unlock()
return nil, err
}
h.coordMu.Unlock()
h.locks.bind(coord.lock)
return coord, nil
}
// 双检失败:等锁期间已有其他插件建好协调器,退回后到者路径。
h.stageMu.Unlock()
}
coord := h.coord
if err := coord.enter(sc, false); err != nil {
h.coordMu.Unlock()
return nil, err
}
h.coordMu.Unlock()
h.locks.bind(coord.lock)
return coord, nil
}
// endStage 由插件 handler 返回时调用。
// 最后离开者:把共享段结果读回内核 StageContext → 压实 arena → 释放 stageMu。
//
// coordMu 必须覆盖「递减 inflight → 判定最后离开者 → 摘除 h.coord」全过程。
// 旧码把 leave() 放在 coordMu 之外,留出了这个窗口(即 2026-09-04 06:56:18
// 线上 fatal error: sync: unlock of unlocked mutex 的真因):
//
// 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(它是后到者),却因为挂进了一个正在收尾的协调器
// 而成为“最后离开者”,于是对同一把锁解了两次。sync.Mutex 的双重解锁是
// runtime fatal,**recover 捕不到**——这就是为何 stage.go / stages.go 里
// 那两层 recover 全部失效、整个 homed 直接死掉的原因。
func (h *Host) endStage(coord *stageCoordinator) error {
h.coordMu.Lock()
last, sc, written := coord.depart()
if last && h.coord == coord {
h.coord = nil
}
h.coordMu.Unlock()
if !last {
return nil
}
// finish 必须在 stageMu.Unlock() 之前:先放锁会让下一轮 stage
// 在回读未完时就改写共享段。
err := coord.finish(sc, written)
h.stageMu.Unlock()
// ★ 先代为释放可能泄漏的锁,再摘除路由(2026-10-07)。
// 顺序很重要:先释放才有意义(锁已无人持有时本就没东西可释放),
// 而摘除必须最后——否则释放期间来的 stage.lock 会打到 nil 上。
//
// 这两件事缺一不可:
// · releaseIfLeaked —— 插件异常路径未 unlock 时,锁不会毒化下一轮
// · unbind —— 下一轮的 stage.lock 不得再打到本轮的旧锁上
if h.locks.releaseIfLeaked(coord.lock) {
log.Printf("[proc] stage 协调器结束时清理了泄漏的 stage 锁")
}
h.locks.unbind(coord.lock)
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 登记一个插件进入本次 stage;first 为真时把内核状态写入共享段。
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 登记一个插件离开;返回是否为最后一个离开者。
//
// 拆成两段:depart() 只动计数(由 endStage 在 coordMu 内调用,使
// 「递减 → 判定最后者 → 摘除 h.coord」成为原子操作),finish() 做
// 共享段回读与压实。本方法保留给单测用。
func (c *stageCoordinator) leave() (last bool, err error) {
last, sc, written := c.depart()
if !last {
return last, nil
}
return last, c.finish(sc, written)
}
// depart 递减 inflight 并报告是否为最后离开者。
func (c *stageCoordinator) depart() (last bool, sc *pubsdk.StageContext, written bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.inflight--
return c.inflight == 0, c.ctxRef, c.written
}
// finish 把共享段结果读回内核 StageContext 并压实 arena
// (此时无插件持锁,满足 §3.3 的压实前提)。
func (c *stageCoordinator) finish(sc *pubsdk.StageContext, written bool) error {
if !written || sc == nil {
return nil
}
if rErr := c.seg.ReadInto(sc); rErr != nil {
return fmt.Errorf("回读共享段: %w", rErr)
}
if reclaimed := c.seg.Compact(); reclaimed > 0 {
log.Printf("[proc] stage 结束,arena 压实回收 %d 字节", reclaimed)
}
return nil
}
// Arena 返回跨进程共享槽池。
func (h *Host) Arena() *arenaRegion { return h.arena }
// Generation 返回统一区域当前 generation(SharedRef 校验用)。
func (h *Host) Generation() uint64 { return h.unified.generation() }
// NextOwnerID 分配一个插件专用的槽 owner ID。
//
// 从 1 开始(OwnerHost=0 保留给内核),单调递增,不会重复。
func (h *Host) NextOwnerID() uint32 { return h.nextOwner.Add(1) }
// ReclaimOwner 回收某个 owner 名下所有槽(插件退出时调用)。
func (h *Host) ReclaimOwner(owner uint32) int { return h.arena.ReclaimOwner(owner) }
// ShmSize 返回共享段大小(供诊断/日志)。
func (h *Host) ShmSize() int { return h.shmSize }
// EvtRing 返回内核侧事件环句柄。
func (h *Host) EvtRing() *EvtRing { return h.evtRing }
// Evtfd 返回通知读端的 *os.File(Unix;eventfd/pipe)。
// Windows 返回 nil——命名 Event 不是文件句柄,用 EvtNotifyFd 代替。
func (h *Host) Evtfd() *os.File { return h.evtfd }
// EvtNotifyFd 返回通知句柄的平台无关标识,供 EventRing 写通知。
//
// Unix 是真 fd;Windows 是映射到命名 Event 句柄的伪 fd。
// EvtfdNotify 接受这个值并按平台分派。
func (h *Host) EvtNotifyFd() int { return h.evtNotifyFd }
// SetEvtSubscriber 注入事件环订阅接口(由 Registry 在创建 Host 后设置)。
func (h *Host) SetEvtSubscriber(sub EvtRingSubscriber) { h.evtSubscriber = sub }
// EvtData 返回事件环段 mmap 数据(子进程消费者用)。
func (h *Host) EvtData() []byte { return h.unified.evtData() }
// EvtfdReadFile 返回 eventfd 的 *os.File(供子进程读取消费)。
func (h *Host) EvtfdReadFile() *os.File { return h.evtfd }