mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
## 现象
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。