mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +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 待办。
85 lines
2.9 KiB
Go
85 lines
2.9 KiB
Go
package proc
|
||
|
||
import (
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// stubSubscriber 是最小 EvtRingSubscriber 实现,用于验证「关停时统一退订」。
|
||
//
|
||
// 为什么必须桩掉 Host.evtSubscriber:真实实现是 internal/plugin.EventRing,
|
||
// 它依赖 Bus(另一个包),在 proc 包内会造成循环依赖。这里只关心
|
||
// Host.Close 是否**调用了** closer —— 真实实现拿到 Close 后的行为
|
||
// 由 internal/plugin 侧测试覆盖。
|
||
type stubSubscriber struct {
|
||
closed atomic.Bool
|
||
// subCount 记录被登记的订阅数(供断言 tracked 语义)
|
||
subCount atomic.Int32
|
||
}
|
||
|
||
func (s *stubSubscriber) EvtRingSubscribe(types []pubsdk.EventType) func() {
|
||
s.subCount.Add(1)
|
||
return func() {}
|
||
}
|
||
|
||
func (s *stubSubscriber) EvtRingSubscribeTracked(types []pubsdk.EventType) func() {
|
||
s.subCount.Add(1)
|
||
return func() {}
|
||
}
|
||
|
||
func (s *stubSubscriber) Close() { s.closed.Store(true) }
|
||
|
||
// TestHost_CloseUnsubscribesEventRing 钉死不变量:
|
||
//
|
||
// Host.Close() 必须在 munmap 共享段**之前**退订事件环。
|
||
//
|
||
// 为什么这是安全不变量:事件环订阅的 handler 会 ring.WritePush(写共享内存)。
|
||
// Host.Close 会 unmap 那块内存;若订阅还在 Bus 上,munmap 后任意一条事件经过
|
||
// Publish 都会让 handler 写已解除映射的内存 ⇒ SIGSEGV。
|
||
// Bus.safeCall 虽有 recover,但 SIGSEGV 是 runtime 致命错误、recover 捕不到,
|
||
// 后果是整个内核进程被杀。
|
||
//
|
||
// 修复前:handleEvents 丢弃取消函数、EventsUnsubscribe 是 no-op、
|
||
// Host.Close 也从不停订阅 —— 每个订阅过的插件都在 Bus 上永久留了一个
|
||
// 写共享内存的 handler,内核关停时必炸。
|
||
func TestHost_CloseUnsubscribesEventRing(t *testing.T) {
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
|
||
sub := &stubSubscriber{}
|
||
host.SetEvtSubscriber(sub)
|
||
|
||
// 模拟插件订阅(走 tracked 路径,与 handleEvents 一致)
|
||
host.evtSubscriber.EvtRingSubscribeTracked([]pubsdk.EventType{pubsdk.EventSystem})
|
||
if sub.subCount.Load() != 1 {
|
||
t.Fatalf("订阅登记数 = %d, want 1", sub.subCount.Load())
|
||
}
|
||
|
||
if err := host.Close(); err != nil {
|
||
t.Fatalf("Close: %v", err)
|
||
}
|
||
|
||
if !sub.closed.Load() {
|
||
t.Fatal("Host.Close 未退订事件环:\n" +
|
||
" munmap 之后 handler 仍挂在 Bus 上,一条事件就会写已解除映射的内存\n" +
|
||
" ⇒ SIGSEGV(recover 捕不到,内核进程被杀)。\n" +
|
||
" 修法:Host.Close 在 freeShm 之前调用 evtCloser.Close。")
|
||
}
|
||
}
|
||
|
||
// TestHost_CloseWithoutSubscriber 确认没设订阅时 Close 不 panic
|
||
// (evtSubscriber 为 nil 是合法状态:未注册任何事件的部署)。
|
||
func TestHost_CloseWithoutSubscriber(t *testing.T) {
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
if err := host.Close(); err != nil {
|
||
t.Fatalf("无订阅者时 Close 应成功: %v", err)
|
||
}
|
||
}
|