mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(guard): 启动宽限期 —— 停机重启后不再误杀加载中的 worker
## 故障(2026-10-12 部署实测,6 连杀 + 服务降级)
停机 34s 后重启:heartbeat 文件 mtime 停在**上一代 worker**(已远超
30s 阈值)。新 worker 要先做完全部重型初始化(chineseclip 512 +
两个静态词向量 + 知识重建,冷启动 5–8s)才写第一次心跳
(startHeartbeat 在 main.go:190,位于所有初始化之后)。
而 guard 的 5s ticker 先到、拿旧 mtime 判 stale → 把还在加载的
worker SIGKILL → 循环重启,guard 进入 failback Safe-Mode:
19:00:28 worker up pid=931403 boot=normal
19:00:33 heartbeat stale for pid=931403 ... SIGKILL ← 加载中
19:00:33 worker up pid=931557 boot=normal
19:00:38 heartbeat stale ... SIGKILL
...(6 连杀后)
19:00:43 3 failures >= max 3, entering failback (Safe-Mode)
worker 自身日志完全健康(graph/multimodal/knowledge 依次就绪),
纯死于"还没来得及写第一次心跳"。
## 根因
heartbeatStale 对「文件不存在」宽限,但对「文件存在、mtime 是
上一代的」不宽限——停机超过 heartbeatTimeout 的每一次重启都会踩。
旧内核未暴露只因它从未停机这么久(上一次是系统重启,mtime 新鲜)。
既有缺陷,block-union 重构未触碰该逻辑(main 分支同病)。
## 修法
spawn 后给一个 heartbeatTimeout 的**启动宽限期**:期间即使心跳
文件 mtime 极旧也不判杀。宽限期满后恢复原判定(卡死 worker 靠
宽限躲不过去)。决策抽成纯函数 shouldKillStaleHeartbeat。
## 判据(TestStartupGraceAllowsInit,变异测试通过)
(a) 宽限期内 + mtime 极旧(上一代遗留)→ 不许杀
(末沿 -50ms 也钉住:ticker 恰在 deadline 前触发是常态)
(b) 宽限期满 + mtime 极旧(真卡死)→ 必须杀
(c) 宽限期满 + mtime 新鲜(健康)→ 不许杀
变异(移除宽限判定、直接 heartbeatStale)→ (a) 两条断言当场 FAIL;
恢复后全绿。TestHeartbeatStale(三分支既有判据)不受影响。
cmd/homed 包全量测试通过;全仓生产编译 GREEN。
This commit is contained in:
@ -335,6 +335,16 @@ func spawnWorkerOnce(exe, dataDir, boot, hbPath string, cfg *guardConfig, sigCh
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
// ★ 启动宽限期:新 worker 要先做完全部重型初始化(graph/文本/向量/
|
||||
// 多模态空间/知识库,实测冷启动约 5–8s)才开始写心跳(main.go:190)。
|
||||
// 而 heartbeat 文件保存的是**上一代 worker** 的 mtime——停机 >heartbeatTimeout
|
||||
// 后,新 worker 还没启动、文件就已"过期"。若无宽限,guard 的 heartbeatInterval
|
||||
// ticker 先到、拿旧 mtime 判 stale → 把还在正常加载的 worker SIGKILL,
|
||||
// 循环重启抖动(2026-10-12 部署实测触发:连续 6 次 failback Safe-Mode)。
|
||||
// 宽限 = 给 worker 完整的启动时长再开始心跳判定。
|
||||
startupGrace := cfg.heartbeatTimeout
|
||||
startupDeadline := time.Now().Add(startupGrace)
|
||||
|
||||
hbTicker := time.NewTicker(cfg.heartbeatInterval)
|
||||
defer hbTicker.Stop()
|
||||
|
||||
@ -374,9 +384,15 @@ func spawnWorkerOnce(exe, dataDir, boot, hbPath string, cfg *guardConfig, sigCh
|
||||
log.Printf("[guard] worker pid=%d crashed: %v", pid, err)
|
||||
return workerCrash, nil
|
||||
case <-hbTicker.C:
|
||||
// IPC PING/ACK 存活判定(带自诊断上报),失败回退文件心跳 mtime
|
||||
// IPC PING/ACK 存活判定(带自诊断上报),失败回退文件心跳 mtime。
|
||||
if st, perr := ipcClient.Ping(cfg.heartbeatTimeout); perr != nil {
|
||||
if heartbeatStale(hbPath, cfg.heartbeatTimeout) {
|
||||
// ★ 启动宽限:新 worker 先做完全部重型初始化(graph/文本/向量/
|
||||
// 多模态空间/知识库,实测冷启动约 5–8s)才写心跳(main.go:190)。
|
||||
// 而 heartbeat 文件 mtime 是**上一代 worker** 的——停机 >heartbeatTimeout
|
||||
// 后,新 worker 还没启动、文件就已"过期"。无宽限时 guard 的 ticker
|
||||
// 先到、拿旧 mtime 判 stale → 把还在加载的 worker SIGKILL,循环抖动
|
||||
// (2026-10-12 部署实测触发:6 次 failback Safe-Mode)。
|
||||
if shouldKillStaleHeartbeat(time.Now(), startupDeadline, hbPath, cfg.heartbeatTimeout) {
|
||||
log.Printf("[guard] heartbeat stale for pid=%d (ipc: %v), treating as hang, SIGKILL", pid, perr)
|
||||
cmd.Process.Kill()
|
||||
<-done
|
||||
@ -389,6 +405,7 @@ func spawnWorkerOnce(exe, dataDir, boot, hbPath string, cfg *guardConfig, sigCh
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeatStale 判心跳文件是否过期(纯函数,见 TestHeartbeatStale)。
|
||||
func heartbeatStale(path string, timeout time.Duration) bool {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
@ -398,6 +415,19 @@ func heartbeatStale(path string, timeout time.Duration) bool {
|
||||
return time.Since(fi.ModTime()) > timeout
|
||||
}
|
||||
|
||||
// shouldKillStaleHeartbeat 在启动宽限期满后,才对陈旧心跳做杀进程判定。
|
||||
//
|
||||
// 纯函数,把 spawnWorkerOnce 里的决策抽出来便于判据覆盖。
|
||||
// 之所以不能只测 heartbeatStale:真正的回归是「拿上一代 worker 的
|
||||
// 旧 mtime,在新 worker 还在加载时就判 stale」——只有与启动宽限
|
||||
// 结合才暴露(见 TestStartupGraceAllowsInit)。
|
||||
func shouldKillStaleHeartbeat(now, startupDeadline time.Time, hbPath string, timeout time.Duration) bool {
|
||||
if now.Before(startupDeadline) {
|
||||
return false // 启动宽限期:不判
|
||||
}
|
||||
return heartbeatStale(hbPath, timeout)
|
||||
}
|
||||
|
||||
// restoreLLMBaseline 从文件基线恢复 core.llm.* 配置(存在才恢复)。
|
||||
func restoreLLMBaseline(dataDir, snapPath string) {
|
||||
if _, err := os.Stat(snapPath); err != nil {
|
||||
|
||||
@ -241,3 +241,55 @@ func TestSnapshotBeforeDefaultsTrue(t *testing.T) {
|
||||
t.Error("failback 缺省应启用")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartupGraceAllowsInit 钉住「启动宽限期」这一部署关键行为。
|
||||
//
|
||||
// ★ 场景(2026-10-12 生产部署实测触发的故障):
|
||||
// 停机 34s 后重启,heartbeat 文件 mtime 停在上一代 worker(已过期),
|
||||
// 新 worker 要先做完全部重型初始化(chineseclip + 静态词向量 + 知识重建,
|
||||
// 冷启动 5–8s)才开始写心跳。guard 的 5s ticker 先到、拿旧 mtime 判 stale
|
||||
// → 把还在加载的 worker SIGKILL → 连续 6 次重启、guard 进入 failback
|
||||
// Safe-Mode(服务降级)。
|
||||
//
|
||||
// 不变量:
|
||||
// (a) 宽限期内,即使心跳文件 mtime 极旧,也**不许**判杀
|
||||
// (b) 宽限期满后,陈旧心跳**必须**判杀(卡死的 worker 不能靠宽限躲过去)
|
||||
// (c) 宽限期满后,新鲜心跳不许判杀(误杀健康 worker 同样是事故)
|
||||
func TestStartupGraceAllowsInit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
hb := filepath.Join(dir, "heartbeat")
|
||||
if err := os.WriteFile(hb, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 上一代 worker 的旧 mtime(停机 34s → 远超 30s 阈值)
|
||||
old := time.Now().Add(-2 * time.Minute)
|
||||
if err := os.Chtimes(hb, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
spawn := time.Now()
|
||||
deadline := spawn.Add(30 * time.Second)
|
||||
|
||||
// (a) 宽限期内(刚 spawn 1s):旧 mtime 也不许杀
|
||||
if shouldKillStaleHeartbeat(spawn.Add(1*time.Second), deadline, hb, 30*time.Second) {
|
||||
t.Error("宽限期内判杀 —— 会把还在加载多模态空间/知识库的 worker SIGKILL(生产已实测触发 6 连杀)")
|
||||
}
|
||||
// 宽限最后一刻同样不许杀(ticker 恰在 deadline 前触发是常态)
|
||||
if shouldKillStaleHeartbeat(deadline.Add(-50*time.Millisecond), deadline, hb, 30*time.Second) {
|
||||
t.Error("宽限期末沿判杀 —— worker 还没来得及写第一次心跳")
|
||||
}
|
||||
|
||||
// (b) 宽限期满 + 心跳仍旧(真的卡死):必须杀
|
||||
if !shouldKillStaleHeartbeat(deadline.Add(1*time.Second), deadline, hb, 30*time.Second) {
|
||||
t.Error("宽限期满后陈旧心跳未判杀 —— 卡死的 worker 靠宽限躲过去,agent 实际不可用")
|
||||
}
|
||||
|
||||
// (c) 宽限期满 + 心跳新鲜(健康 worker):不许杀
|
||||
fresh := time.Now()
|
||||
if err := os.Chtimes(hb, fresh, fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shouldKillStaleHeartbeat(deadline.Add(2*time.Second), deadline, hb, 30*time.Second) {
|
||||
t.Error("宽限期满后新鲜心跳被判杀 —— 误杀健康 worker")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user