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
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 全部插件离开。