mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
## 症状
全量 go test 偶发 SIGSEGV,整个测试二进制被杀(recover 捕不到 runtime
致命错误)。崩溃栈(2026-09-25 实测捕获,完整):
readLoop (process.go:334)
→ markExited → once.Do
→ onExit → Plugin.handleExit (plugin.go:209)
→ Host.ReclaimOwner (host.go:342)
→ arenaRegion.ReclaimOwner → blockBase → getU32
→ SIGSEGV 读已 munmap 的内存
## 根因(两个叠加缺陷,同一后果)
**① markExited 里 close(exited) 早于 onExit**
close(p.exited) // :394 —— 先关闭,唤醒所有等待者
p.sup.untrack(p.name)
p.onExit(...) // :399 —— 回调里要读共享内存
onExit(内核侧 Plugin.handleExit)会调 Host.ReclaimOwner 回收该插件残留的
共享槽,那是要读共享内存区域的。而 exited 一关闭,Stop()/Kill() 就返回
(process.go:566/587),StopAll 随即返回,调用方(Host.Close)立刻
freeShm 解除映射 —— 此刻 onExit 还没跑完,ReclaimOwner 就成了读已 munmap
的内存。
修法:把 onExit 提到 close(p.exited) **之前**,并明确 exited 的语义是
「**完全**收尾完毕」而非「进程已死」——任何等待者看到它关闭后,都可安全
释放共享内存、卸载资源。
**② StopAll 超时分支 `go p.Kill()` 发射后不管**
go p.Kill() // 不等待
本函数返回后调用方就 unmap,而 Kill 内部要等 markExited 跑完(含 onExit)。
改为等全部 Kill 完成(Kill 自带 killReapTimeout 上限,不会无限拖住关停)。
**③ 同类的第三处:事件环订阅在关停时从不退订**
EventRing.Subscribe 注册到 Bus 的 handler 会 ring.WritePush(写共享内存),
而 Host.Close 会 munmap 整块区域。此前:
- handleEvents 把 EvtRingSubscribe 的取消函数**直接丢弃**(corehandler_runtime.go:103)
- EventsUnsubscribe 是 no-op,注释还写着「子进程 Stop 时由内核统一清理」,
但 closeProcHost 根本没有退订
于是每个订阅过的插件都在 Bus 上永久留了一个写共享内存的 handler,
munmap 后任意一条事件经过 Publish 就会写已解除映射的内存 ⇒ 同类 SIGSEGV。
修法:EventRing 记为 unsubs、新增 EvtRingSubscribeTracked(订阅即登记),
Host.Close 在 freeShm **之前**调用 evtCloser.Close 统一退订。
## 回归测试(3 个,均经变异验证「修复前判红」)
1. `TestProcess_OnExitCompletesBeforeExitedCloses`(proc)
断言 Exited() 关闭时 onExit 必须已返回。变异(把 close(exited) 挪回
onExit 之前)→ FAIL。这是本次崩溃的直接判据。
2. `TestHost_CloseUnsubscribesEventRing`(proc)
断言 Host.Close 调用了 evtCloser.Close。变异(撤掉退订)→ FAIL。
3. `TestEventRing_CloseUnsubscribesFromBus`(plugin)
端到端:订阅 → Publish 有写入 → Close → Publish 不再写入。变异
(Close 不做事)→ FAIL。
顺带给 EvtRing 加了 Written() 访问器(诊断 + 上述测试的可观察量)。
## 验证
- 三个新测试全绿;-race 下 ./internal/plugin/... 全绿
- proc 包连跑 12 轮、internal/plugins 连跑 20 轮:SIGSEGV 0 次
- 全量 go test -count=1 ./... → 38 ok / 0 FAIL
- go build ./... / go vet ./... 干净
## 另有两个**既有** flaky(本次未动,与 C 化无关,单独记录)
排查过程中用「我的树 20 轮 vs 干净树 20 轮」对照确认了归属:
- 测试间固定端口冲突(pluginmgr 9876 / remotedevice 9890 / webui 8080):
并行或残留实例时报 bind: address already in use。干净树同样复现。
- TestRealPlugin_DeepSearchInvoke 依赖外部 SearXNG(127.0.0.1:8888):
上游限流时(brave "too many requests"、duckduckgo/quark CAPTCHA)断言失败。
干净树同样复现。属外部依赖,不是代码缺陷。
两者都不属本次「排查崩溃」的范围,已记入 plan.md 待办。
79 lines
3.0 KiB
Go
79 lines
3.0 KiB
Go
package proc
|
||
|
||
import (
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestProcess_OnExitCompletesBeforeExitedCloses 钉死一条**顺序不变量**:
|
||
//
|
||
// Exited() 通道关闭时,onExit 回调必须**已经返回**。
|
||
//
|
||
// ============================ 为什么这是一条安全不变量 ============================
|
||
// onExit(内核侧 Plugin.handleExit)会调 Host.ReclaimOwner 回收残留共享槽
|
||
// —— 那要读共享内存区域。而任何等待者(Stop/Kill/CallContext/Alive)看到
|
||
// Exited() 关闭就会认为「完全收尾」,进而释放资源(Host.Close 会 freeShm
|
||
// 解除整块 mmap)。
|
||
//
|
||
// 若 Exited() 先于 onExit 返回而关闭,就会出现:
|
||
// 等待者 → 释放映射 → onExit 仍在读那块内存 → SIGSEGV
|
||
// 这正是 2026-09-25 全量测试偶发崩溃的根因(栈见 process.go 的 markExited 注释)。
|
||
//
|
||
// ============================ 为什么用原子标志而非 channel ============================
|
||
// 要断言的是「关闭**之前**回调已完成」这一 happened-before 关系。
|
||
// 用一个在回调里置位的原子量 + 在收到关闭信号后立刻读它:
|
||
// - 修复前:关闭先发生,回调尚未跑 ⇒ 读到 false ⇒ 判红
|
||
// - 修复后:回调先跑完再关闭 ⇒ 读到 true ⇒ 判绿
|
||
// 用 atomic 而非普通 bool 是为了让「回调的写」与「测试的读」之间
|
||
// 有明确的同步语义(否则是数据竞态,-race 下会报)。
|
||
func TestProcess_OnExitCompletesBeforeExitedCloses(t *testing.T) {
|
||
bin := buildTestPlugin(t, "crashplugin.go")
|
||
|
||
var onExitDone atomic.Bool
|
||
exitCh := make(chan struct{})
|
||
|
||
p, err := Spawn("exitorder", bin, Options{
|
||
Handler: noopHandler,
|
||
OnExit: func(name string, err error) {
|
||
// 模拟 handleExit 里的 ReclaimOwner:真实实现要读共享内存,
|
||
// 这里用一个短暂延迟把「回调还在跑」这个窗口放大到可观测。
|
||
// 关键:置位发生在**回调返回之前**。
|
||
time.Sleep(50 * time.Millisecond)
|
||
onExitDone.Store(true)
|
||
close(exitCh)
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Spawn: %v", err)
|
||
}
|
||
defer p.Kill()
|
||
|
||
// 触发插件 panic 自杀
|
||
if _, err := p.Call(MethodToolInvoke, ToolInvokeParams{Name: "boom"}); err == nil {
|
||
t.Error("崩溃插件应返回错误")
|
||
}
|
||
|
||
// 等 Exited() 关闭 —— 此后任何等待者都会认为「可以安全 unmap」
|
||
select {
|
||
case <-p.Exited():
|
||
case <-time.After(10 * time.Second):
|
||
t.Fatal("10s 内未观测到进程退出")
|
||
}
|
||
|
||
// ★ 核心断言:Exited() 已关闭时,onExit 必须已经跑完。
|
||
if !onExitDone.Load() {
|
||
t.Fatal("顺序违例:Exited() 已关闭,但 onExit 尚未返回。\n" +
|
||
" 后果:等待者(Stop/Kill/Host.Close)会立刻 freeShm 解除映射,\n" +
|
||
" 而 onExit 里的 ReclaimOwner 仍要读共享内存 ⇒ SIGSEGV。\n" +
|
||
" 修法:markExited 中 close(p.exited) 必须放在 onExit 之后。")
|
||
}
|
||
|
||
// 顺带确认回调确实被调用过(而非因 bug 整个跳过)
|
||
select {
|
||
case <-exitCh:
|
||
default:
|
||
t.Fatal("onExit 未在 Exited() 关闭前完成")
|
||
}
|
||
}
|