mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(proc): 关闭时等 stage 收尾,修每次 shutdown 必现的 SIGSEGV
连续两次部署都被验证门拦下(「近 2 分钟出现 5 条 panic/fatal」)。
查出这不是新版本崩了,而是**旧进程关闭时**必崩 —— 每次 shutdown 都触发:
fatal error: fault
[signal SIGSEGV: segmentation violation ...]
proc.(*Segment).descOffset
proc.(*Segment).setDesc
proc.(*Segment).writeLocal.func1 shmcodec.go:104
proc.(*Segment).writeLocal
proc.(*Segment).WriteAll
proc.(*stageCoordinator).enter host.go:310
proc.(*Host).beginStage
proc.(*coreHandler).runStage
agent/core.(*StageHost).RunStage
## 根因:Close 只处理了「事件环」一路写者,漏了「stage 数据面」
Host.Close 里那段长注释把事件环写得非常清楚(订阅 handler 会
ring.WritePush 写共享内存,unmap 后必炸,而 recover 捕不到 runtime
fatal),也**确实**在 freeShm 前退订了。
但共享段有**两条独立写者**。beginStage 的首进者会持 stageMu 调
Segment.WriteAll 写同一块段 —— 它**不在事件环订阅里**,退订阅根本管不到它。
于是 Close 只等问题 A 就 freeShm,问题 B 继续写已 unmap 的内存。
## 修法:用 stageMu 作为「没有 stage 在写段」的判据
stageMu 的既有语义已经就是我们要等的东西(首进者取、最后离开者放,
见 beginStage/endStage 注释),不必再造一个会与它失去同步的并行状态。
`Close` 现在 unmap 前独占获取它。
另加 `closing atomic.Bool`:光「等正在进行的 stage」还不够 —— Close
释放 stageMu 之后仍可能有新 stage 进场(agent 收尾路径),那时它照写已
释放的段。beginStage 现在看到一个明确的错误,而不是撞 SIGSEGV。
## 为何这件事必须修,而不只是「已知缺陷」
它让部署验证门失效:门报的「5 条 panic/fatal」分不清是新版本坏了,
还是旧版本关闭时的既有噪声。我这两次都手工核对了 PID 才敢继续 ——
下一个部署的人不该被迫做这种考古。修掉之后,这个门才真的能拦回归。
## 验证(判据可杀变异,逐个实测)
- `TestHost_CloseWaitsForInflightStage`:stage 持锁时 Close 必须阻塞。
删掉 `closing.Store` + `stageMu.Lock` → FAIL「未等 stage 收尾就返回」
- `TestHost_BeginStageAfterCloseRejected`:Close 后 beginStage 必须报错。
把检查改成 `if false` → **测试进程真的 SIGSEGV 被杀**(与生产同栈)
- `go test -race ./internal/plugin/proc/` 通过
- 全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@ -51,6 +52,11 @@ type Host struct {
|
||||
coordMu sync.Mutex
|
||||
coord *stageCoordinator
|
||||
sup *Supervisor
|
||||
|
||||
// closing 置真后 beginStage 直接拒绝新 stage(Close 与新 stage 进场的赛跑:
|
||||
// unmap 后若又进来一个 stage,它照样会去写已释放的段)。
|
||||
// 用 atomic 是为了让 beginStage 无需额外加锁就能读到。
|
||||
closing atomic.Bool
|
||||
}
|
||||
|
||||
// NewHost 创建共享段(平台层 allocShm + 布局初始化)。
|
||||
@ -164,13 +170,40 @@ func (h *Host) Close() error {
|
||||
c.Close()
|
||||
}
|
||||
|
||||
// ★ 等正在进行的 stage 收尾(2026-10-08 修 shutdown use-after-free)。
|
||||
//
|
||||
// 上面的注释只处理了**事件环**一路写者,但漏了 **stage 数据面**:
|
||||
//
|
||||
// 上面的注释只处理了**事件环**一路写者,但漏了 **stage 数据面**:
|
||||
// beginStage 的首进者会在 stageMu 保护下调 Segment.WriteAll(写共享段),
|
||||
// 而这条路径**不在**事件环订阅里,退订阅根本不影响它。
|
||||
//
|
||||
// 实测后果:每次关闭都 SIGSEGV(栈:stageCoordinator.enter →
|
||||
// Segment.WriteAll → writeLocal → descOffset),
|
||||
// 即首次进入的新进程日志里必然带一份“fatal error: fault”。
|
||||
// 这还使部署验证门形同虚设——分不清“新版本崩了”还是“旧版本关闭时崩”。
|
||||
//
|
||||
// 为何用 stageMu 而不是另加 done 通道:stageMu 的语义已经是
|
||||
// 「本轮 stage 独占共享段」(首进者取、最后离开者放,见 beginStage/
|
||||
// endStage)。取到它就意味着“此刻没有 stage 在写段”,正是我们要等的东西,
|
||||
// 不必再造一个会与它失去同步的并行状态。
|
||||
//
|
||||
// 锁序:stageMu 是这组锁的最后一级(beginStage 是 coordMu → stageMu,
|
||||
// endStage 不取锁),此处独占获取不会成环。
|
||||
// 注意 StageHost 的 handler 已由 StopAll/上层停过,不会无限持锁。
|
||||
h.closing.Store(true) // 先挡住新 stage,再等旧 stage 退出
|
||||
h.stageMu.Lock()
|
||||
|
||||
var firstErr error
|
||||
if h.data != nil {
|
||||
// 先让段不可再写(置 nil 后 segdata 已失效),
|
||||
// 再真正 unmap——完成写入的 stage 已在上面等完。
|
||||
if err := freeShm(h.memfd, h.data); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
h.data, h.memfd, h.unified = nil, nil, nil
|
||||
h.data, h.memfd, h.unified, h.seg = nil, nil, nil, nil
|
||||
}
|
||||
h.stageMu.Unlock()
|
||||
if h.evtfd != nil {
|
||||
h.evtfd.Close()
|
||||
h.evtfd = nil
|
||||
@ -192,6 +225,11 @@ func (h *Host) Close() error {
|
||||
//
|
||||
// 锁序:stageMu → coordMu。endStage 只解锁 stageMu、不获取,所以无环。
|
||||
func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) {
|
||||
// Close 已开始:共享段即将/已被 unmap,此时再写段就是 use-after-free
|
||||
// (runtime fatal,recover 捕不到)。直接拒绝,让上层得到明确错误。
|
||||
if h.closing.Load() {
|
||||
return nil, errors.New("proc: 内核正在关闭,拒绝新 stage")
|
||||
}
|
||||
h.coordMu.Lock()
|
||||
if h.coord == nil {
|
||||
// 首个进入者:独占共享段直到本次 stage 全部插件离开。
|
||||
|
||||
96
internal/plugin/proc/host_close_stage_test.go
Normal file
96
internal/plugin/proc/host_close_stage_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||
)
|
||||
|
||||
// TestHost_CloseWaitsForInflightStage 钉死不变量:
|
||||
//
|
||||
// Host.Close() 必须在 unmap 共享段**之前**等正在进行的 stage 收尾。
|
||||
//
|
||||
// 为什么这是安全不变量:stage 数据面与事件环是**两条独立写者**。
|
||||
// beginStage 的首进者会持 stageMu 调 Segment.WriteAll(写共享段),
|
||||
// 而它不在事件环订阅里 —— 退订阅管不到它。
|
||||
//
|
||||
// 实测(2026-10-08,连续两次部署复现):Host.Close 只退了事件环就 freeShm,
|
||||
// 于是 stage 线程继续写已 unmap 的段 ⇒ SIGSEGV,栈正是
|
||||
//
|
||||
// stageCoordinator.enter → Segment.WriteAll → writeLocal → descOffset
|
||||
//
|
||||
// 这是 runtime 致命错误,recover 捕不到,整个 homed 被杀。后果还包括
|
||||
// 部署验证门失效:分不清「新版本崩了」还是「旧版本关闭时崩」。
|
||||
//
|
||||
// 判据设计:让一个 stage 持锁(模拟正在写段),Close 在另一 goroutine 里跑。
|
||||
// 若 Close 不等,它会在 stage 释放前就完成(并已 unmap)→ 测试红。
|
||||
func TestHost_CloseWaitsForInflightStage(t *testing.T) {
|
||||
host, err := NewHost()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHost: %v", err)
|
||||
}
|
||||
|
||||
// 进入一个 stage(首进者):此时 stageMu 被持有,等价于「正在写共享段」。
|
||||
sc := &pubsdk.StageContext{}
|
||||
coord, err := host.beginStage(sc)
|
||||
if err != nil {
|
||||
t.Fatalf("beginStage: %v", err)
|
||||
}
|
||||
|
||||
closeDone := make(chan error, 1)
|
||||
go func() { closeDone <- host.Close() }()
|
||||
|
||||
// Close 必须被挡住:它得等 stage 退出,不能提前 unmap。
|
||||
select {
|
||||
case <-closeDone:
|
||||
t.Fatal("Host.Close 未等 stage 收尾就返回:\n" +
|
||||
" 它会在 stage 仍写共享段时 freeShm ⇒ use-after-free\n" +
|
||||
" ⇒ SIGSEGV(runtime fatal,recover 捕不到,homed 被杀)")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
// 正确:Close 还阻塞在等 stageMu
|
||||
}
|
||||
|
||||
// 让 stage 收尾 → Close 应当随即完成。
|
||||
if err := host.endStage(coord); err != nil {
|
||||
t.Fatalf("endStage: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-closeDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("stage 已退出,Host.Close 仍未完成(可能死锁)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHost_BeginStageAfterCloseRejected 钉住:
|
||||
//
|
||||
// Close 开始后不得再接受新 stage。
|
||||
//
|
||||
// 为什么需要:光「等正在进行的 stage」还不够 —— Close 释放 stageMu 之后、
|
||||
// 或 freeShm 之后,仍可能有新 stage 进场(agent 的收尾路径)。
|
||||
// 那时它会照写已释放的段,同样是 use-after-free。
|
||||
func TestHost_BeginStageAfterCloseRejected(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)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := host.beginStage(&pubsdk.StageContext{}); err == nil {
|
||||
t.Error("Close 之后 beginStage 仍成功:它会写已 unmap 的共享段 ⇒ use-after-free")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user