diff --git a/internal/plugin/proc/host.go b/internal/plugin/proc/host.go index 73f5ae57..e47a5351 100644 --- a/internal/plugin/proc/host.go +++ b/internal/plugin/proc/host.go @@ -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 全部插件离开。 diff --git a/internal/plugin/proc/host_close_stage_test.go b/internal/plugin/proc/host_close_stage_test.go new file mode 100644 index 00000000..4782256a --- /dev/null +++ b/internal/plugin/proc/host_close_stage_test.go @@ -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() +}