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:
JianFeeeee
2026-10-08 12:46:02 +08:00
parent 8336509bfa
commit aaf833a687
2 changed files with 135 additions and 1 deletions

View File

@ -1,6 +1,7 @@
package proc package proc
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -51,6 +52,11 @@ type Host struct {
coordMu sync.Mutex coordMu sync.Mutex
coord *stageCoordinator coord *stageCoordinator
sup *Supervisor sup *Supervisor
// closing 置真后 beginStage 直接拒绝新 stage(Close 与新 stage 进场的赛跑:
// unmap 后若又进来一个 stage,它照样会去写已释放的段)。
// 用 atomic 是为了让 beginStage 无需额外加锁就能读到。
closing atomic.Bool
} }
// NewHost 创建共享段(平台层 allocShm + 布局初始化)。 // NewHost 创建共享段(平台层 allocShm + 布局初始化)。
@ -164,13 +170,40 @@ func (h *Host) Close() error {
c.Close() 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 var firstErr error
if h.data != nil { if h.data != nil {
// 先让段不可再写(置 nil 后 segdata 已失效),
// 再真正 unmap——完成写入的 stage 已在上面等完。
if err := freeShm(h.memfd, h.data); err != nil && firstErr == nil { if err := freeShm(h.memfd, h.data); err != nil && firstErr == nil {
firstErr = err 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 { if h.evtfd != nil {
h.evtfd.Close() h.evtfd.Close()
h.evtfd = nil h.evtfd = nil
@ -192,6 +225,11 @@ func (h *Host) Close() error {
// //
// 锁序:stageMu → coordMu。endStage 只解锁 stageMu、不获取,所以无环。 // 锁序:stageMu → coordMu。endStage 只解锁 stageMu、不获取,所以无环。
func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) { 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() h.coordMu.Lock()
if h.coord == nil { if h.coord == nil {
// 首个进入者:独占共享段直到本次 stage 全部插件离开。 // 首个进入者:独占共享段直到本次 stage 全部插件离开。

View 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()
}