fix(proc): 修 arena use-after-unmap 导致的内核 SIGSEGV(关停竞态)

## 症状

全量 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 待办。
This commit is contained in:
JianFeeeee
2026-09-25 17:25:43 +08:00
parent 0d4399c173
commit 8f52bfc10b
10 changed files with 334 additions and 6 deletions

View File

@ -0,0 +1,78 @@
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() 关闭前完成")
}
}