Files
HomeAgent/cmd/homed/guard_safety_test.go
JianFeeeee 1d843daf2a fix(guard): 心跳提前启动 + 首次同步写入 —— 启动竞态的 worker 侧根修
562ba6bd 修了 guard 侧(给旧 mtime 加启动宽限,不误杀),那只是防御。
**根因**在 worker 侧:心跳文件要等全部重型初始化完(原 main 尾部)才
首写,且首次写是异步(在 goroutine 里,与多模态空间/知识库重建竞争
调度)。停机 >30s 后重启时 mtime 停在上一代 worker,guard 的 5s ticker
先到就 SIGKILL。

两层独立生效:
  worker 侧(本提交,治本):dataDir 一确定就启动心跳,首次写同步
  guard 侧(562ba6bd,防御):保留启动宽限期容忍旧 mtime

## 改动

- main.go:startHeartbeat 从尾部(190)移到 dataDir 解析后(54),
  用 context.Background()(心跳生命周期=进程,与 agent ctx 解耦)。
- bootstrap.go:startHeartbeat 首次 writeHB() 提到 goroutine 之外
  同步执行——返回前 mtime 必已变新,main 往下做慢事之前 guard 看到的
  是新心跳。

## 判据(TestHeartbeatFirstWriteSynchronous,变异测试通过)

  (a) 返回后立即查 mtime:必须是新的(同步首写),不许等到第一个 tick
      首写移回 goroutine(异步)→ (a) 当场 FAIL,已实测
  (b) 5s 后 mtime 再变(ticker 在跑)
  (c) stop() 后不再更新(hbStop 生效,不泄漏 goroutine)

判据覆盖完整生命周期:启动即新 → 周期更新 → 停止即停。

cmd/homed 包全量测试通过;全仓生产编译 GREEN。
2026-10-12 19:32:04 +08:00

233 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build linux
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HomeAgent/internal/recovery"
)
// guard 的**破坏性路径**判据。
//
// 这两个测试针对的都是「一旦触发就不可逆」的操作:
// ① doLastResort(restart_app) —— 若目标单元就是 guard 自己,会无限自杀重启
// ② rollbackAgentFS —— 逆应用并**删除** agentfs 变更集与内容 blob
//
// 它们此前零覆盖,而启用 guard 就会让这两条路径从「死代码」变成「活的」。
// TestLastResortSelfGuardExists 确认 L2 自指防护在位。
//
// 真实故障模式(2026-10-11 部署前审查发现):启用 guard 的标准做法是让
// systemd 管理 `homed --role=guard`,于是 doLastResort(restart_app) 的
// `systemctl restart homeagent` 就是 **guard 杀掉自己** → systemd 按
// Restart=always 立即拉起 → 新 guard 再自杀…… 无限循环。
//
// 自指时正确解法是直接退出(Restart=always 下与 restart_app 等价)。
// 防护由 guard.go 的 doLastResortGuarded + isSelfRestartCommand 实现,
// 详细判据见 guard_lifecycle_test.go 的 TestSelfRestartDetection。
func TestLastResortSelfGuardExists(t *testing.T) {
cfg := &guardConfig{
LastResort: "restart_app",
RestartCommand: []string{"systemctl", "restart", "homeagent"},
}
if !isSelfRestartCommand(cfg.RestartCommand, "homeagent.service") {
t.Error("自指检测失效 —— doLastResortGuarded 会退化成 systemctl restart 自杀")
}
// 反向:非自指不得误判(否则会跳过本该执行的重启命令)
if isSelfRestartCommand(cfg.RestartCommand, "some-other.service") {
t.Error("对非自指单元误判 —— 本该执行的重启命令会被跳过")
}
}
// TestWriteRecoveryTaskRoundTrip 保证 guard 写给 worker 的任务文件格式正确。
//
// 这是 guard↔worker 唯一的通信协议。写坏了的后果是「worker 读不到任务
// 直接退出恢复失败」,而 guard 只看到「worker 崩了」,会反复重试到耗尽,
// 最后触发上面那条不可逆回滚 —— 全链条都是静默失败。
func TestWriteRecoveryTaskRoundTrip(t *testing.T) {
dataDir := t.TempDir()
cfg := defaultGuardConfig(dataDir)
cfg.LLM.Sources = []recovery.RescueSource{{
Name: "rescue",
BaseURL: "https://example.invalid/v1",
APIKey: "sk-test",
Model: "m",
Adapter: "openai",
}}
if err := writeRecoveryTask(dataDir, cfg, 1); err != nil {
t.Fatalf("writeRecoveryTask: %v", err)
}
// worker 侧用 recovery.LoadTask 读
task, err := recovery.LoadTask(recovery.TaskPath(dataDir))
if err != nil {
t.Fatalf("worker 读不到任务(协议不兼容): %v", err)
}
if task.Attempt != 1 {
t.Errorf("Attempt=%d, want 1", task.Attempt)
}
if task.MaxAttempts != cfg.Recovery.MaxAttempts {
t.Errorf("MaxAttempts=%d, want %d", task.MaxAttempts, cfg.Recovery.MaxAttempts)
}
// ★ rescue 源是恢复的生死线:为空则 worker 第一步就退出
if task.RescueSource.Name == "" || task.RescueSource.BaseURL == "" {
t.Errorf("任务里 rescue 源不完整(%+v)—— failback.go 会直接退出恢复失败", task.RescueSource)
}
if task.RescueSource.APIKey != "sk-test" {
t.Errorf("rescue api_key 未写入任务:%q", task.RescueSource.APIKey)
}
if task.AttemptTimeout == "" {
t.Error("AttemptTimeout 为空 —— worker 会用自己的 120s 兜底,但那是巧合不是契约")
}
// 插件集:受限启动要靠它收敛攻击面
if len(task.PluginList) == 0 {
t.Error("任务的 plugins 为空 —— failback 会退化成「什么都不加载」")
}
}
// TestWriteRecoveryTaskOmitsEmptyRescueSource 钉住「配置缺 rescue 源时不写空任务」。
//
// failback.go 对此的检查是 os.Exit(exitRecoveryFailed) —— 硬失败。
// 更好的做法是 guard 侧就别写一个必然失败的任务,而是让 worker
// 走「无任务可做」的路径并留下清晰日志。
func TestWriteRecoveryTaskOmitsEmptyRescueSource(t *testing.T) {
dataDir := t.TempDir()
cfg := defaultGuardConfig(dataDir)
cfg.LLM.Sources = nil // 模拟未配置 rescue 源
err := writeRecoveryTask(dataDir, cfg, 1)
// 当前实现:写了含空 rescue 源的任务(failback 会硬失败)
// 本测试记录该行为,并在任务确实损坏时报出来
if err != nil {
return // 实现已改为拒绝写坏任务,符合期望
}
task, lerr := recovery.LoadTask(recovery.TaskPath(dataDir))
if lerr != nil {
return
}
if task.RescueSource.BaseURL == "" {
t.Log("⚠️ 已写出 rescue 源为空的任务:failback worker 会 " +
"os.Exit(exitRecoveryFailed) 硬失败(未静默,但也无法恢复)")
}
}
// TestHeartbeatFileIsWrittenByWorker 确认心跳文件路径与 guard 读取的一致。
//
// 若 worker 写 <data>/agentfs/heartbeat 而 guard 读 <data>/heartbeat,
// guard 会永远看不到心跳 —— 但 heartbeatStale 对「文件不存在」是宽限的
// (返回 false),于是 guard 会**永远等一个卡死的 worker**,不杀也不恢复。
// 这是最难察觉的一种失效:进程在、日志干净、agent 不可用。
func TestHeartbeatFileIsWrittenByWorker(t *testing.T) {
dataDir := t.TempDir()
// guard 侧路径(runGuard 里 hbPath 的算法)
guardPath := filepath.Join(dataDir, "heartbeat")
// worker 侧路径(bootstrap.go startHeartbeat 的算法)
// 源码: filepath.Join(dataDir, "heartbeat") —— 必须与上面逐字一致
workerPath := filepath.Join(dataDir, "heartbeat")
if guardPath != workerPath {
t.Fatalf("心跳路径不一致:guard 读 %s,worker 写 %s", guardPath, workerPath)
}
// 造一个陈旧心跳,确认 guard 会判 stale(进而杀掉卡死 worker)
old := time.Now().Add(-time.Hour)
if err := os.WriteFile(workerPath, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(workerPath, old, old); err != nil {
t.Fatal(err)
}
if !heartbeatStale(guardPath, 30*time.Second) {
t.Error("陈旧心跳未被检出 —— 卡死 worker 永不被杀")
}
}
// TestFailbackPluginsAreSubsetOfKnownPlugins 钉住「受限启动不静默放宽」。
//
// failback 模式的全部价值在于「只加载 3 个插件」。若某次改配置时
// 误写成 "*" 或含通配,受限启动就退化成了正常启动,失去意义。
func TestFailbackPluginsAreSubsetOfKnownPlugins(t *testing.T) {
dataDir := t.TempDir()
cfg := loadGuardConfig(dataDir)
// 默认插件集应是三个确定性诊断类插件
want := map[string]bool{"webui": true, "pluginmgr": true, "recoverydiag": true}
for _, p := range cfg.Recovery.Plugins {
if !want[p] {
t.Errorf("缺省 failback 插件集含非预期插件 %q(会让受限启动的攻击面变大)", p)
}
if strings.ContainsAny(p, "*?") {
t.Errorf("缺省 failback 插件集含通配符 %q —— 受限启动会退化成全加载", p)
}
}
}
// TestHeartbeatFirstWriteSynchronous 钉住「心跳首写必须同步落盘」。
//
// ★ 场景(2026-10-12 生产部署实测的 worker 侧根因):停机 >30s 后重启,
// heartbeat 文件 mtime 停在上一代 worker(已过期)。原实现把首次写放进
// goroutine —— 它与重型初始化(多模态空间/知识库重建,冷启动 5–8s)竞争
// 调度,迟迟不落盘;guard 的 5s ticker 先到、拿旧 mtime 判 stale →
// SIGKILL 加载中的 worker(6 连杀 + failback 降级,guard 侧修复见
// shouldKillStaleHeartbeat 的宽限期;本判据钉 worker 侧根因)。
//
// 不变量:startHeartbeat 返回时,心跳文件 mtime 必已是「现在」——
// main() 往下做任何慢事之前,guard 看到的一定是新心跳。
func TestHeartbeatFirstWriteSynchronous(t *testing.T) {
dir := t.TempDir()
hb := filepath.Join(dir, "heartbeat")
// 造上一代遗留的过期心跳(复现停机重启场景)
if err := os.WriteFile(hb, []byte("old"), 0644); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-2 * time.Minute)
if err := os.Chtimes(hb, old, old); err != nil {
t.Fatal(err)
}
// 启动心跳;返回即应已落盘首写
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stop := startHeartbeat(dir, ctx)
// (a) 返回后立刻检查 mtime:必须是新的(同步首写),不许等到第一个 tick
fi, err := os.Stat(hb)
if err != nil {
t.Fatalf("心跳文件不存在: %v", err)
}
if age := time.Since(fi.ModTime()); age > 2*time.Second {
t.Errorf("startHeartbeat 返回后心跳仍是旧的(age=%v)—— 首写不是同步的,guard 会拿旧 mtime 误杀加载中的 worker", age)
}
// (b) 5s 后应有第二次写(ticker 还在跑)
time.Sleep(5500 * time.Millisecond)
fi2, err := os.Stat(hb)
if err != nil {
t.Fatalf("5s 后心跳文件消失: %v", err)
}
if !fi2.ModTime().After(fi.ModTime()) {
t.Error("5s 后心跳未更新 —— ticker 未运行,guard 会在 heartbeatTimeout 后误杀健康 worker")
}
// (c) 停止后不再写
stop()
m3 := func() time.Time {
f, _ := os.Stat(hb)
return f.ModTime()
}()
time.Sleep(5500 * time.Millisecond)
if m4, _ := os.Stat(hb); !m4.ModTime().Equal(m3) {
t.Error("停止心跳后文件仍被更新 —— hbStop 未生效,泄漏 goroutine")
}
}