Files
HomeAgent/internal/tracker/rollback_irreversible_test.go
JianFeeeee a7f89a1b45 fix(guard)!: L2 自杀防护 + 配置零值兜底 + 补齐零覆盖的测试
部署 guard 前的审查发现两个**致命**缺陷。它们此前都零测试覆盖 ——
正是「没测过才敢发现」。

## ① L2 自杀循环(启用 guard 就会触发)

doLastResort(restart_app) 执行 `systemctl restart <unit>`,而启用 guard 的
标准做法正是让 systemd 管理 `homed --role=guard` —— <unit> 就是它自己:

    guard: doLastResort → systemctl restart homeagent
           → 杀掉自己 → systemd 按 Restart=always 拉起新 guard
           → 新 guard 再 spawn 失败 → 再自杀 …… 无限循环

每轮白跑一遍完整启动。与 2026-09-05 那次 systemd 审计记录的事故同类
(坏配置导致 836 次重启、79 分钟)。

### 修法:自指时直接退出,而不是换个单元名

「换个别的服务」语义完全不对 —— L2 的本意就是重启自己。而
`Restart=always` 已经提供了这件事:**guard 退出 == systemd 重新拉起 ==
重启整个单元**。所以自指时 os.Exit(41) 与 restart_app 完全等价,且无自杀窗口。

- `doLastResortGuarded(cfg, unit)`:判自指 → 退出 41;否则原逻辑
- `detectOwnSystemdUnit()`:读 /proc/self/cgroup(cgroup v2),兜底
  $INVOCATION_ID → /run/systemd/invocation/<id>。**拿不到就返回空字符串**
  让调用方跳过检测 —— 不猜单元名(猜错 = 拦错/杀错服务)
- `exitGuardRestartRequested = 41`:刻意避开 worker 的 42-44 区间
- 两处 doLastResort 调用点全部改走防护版

## ② 配置损坏 → agent 装上就崩

defaultGuardConfig **不解析时长字符串**(只给 "30s" 这类字面量),
parseDur 在 loadGuardConfig 里、且在 yaml.Unmarshal **失败时提前 return
之后**——那段根本不执行。

于是 guard.yaml 一旦写坏(缩进错、半截内容,手改文本极常见):

| 字段 | 实际值 | 后果 |
|---|---|---|
| heartbeatTimeout | **0** | `time.Since(mtime) > 0` 恒真 → **每轮杀 worker** → 无限重启 |
| heartbeatInterval | 0 | `time.NewTicker(0)` panic |
| attemptTimeout | 0 | 恢复 probe 立即超时 → L1 梯子必败 |
| Recovery.Plugins | 空 | 受限启动什么都不加载 |

这不是「配置没生效」,是「装上就崩」。修法:把 parseDur 与插件集缺省
移进 defaultGuardConfig,任何路径都有正值兜底。

## ③ 测试:0 → 25 条(全可杀变异)

| 文件 | 覆盖 |
|---|---|
| `guard_config_test.go` | 配置默认值/YAML/损坏/越界 + 心跳三态 |
| `guard_lifecycle_test.go` | worker 生命周期:6 种退出码分类、信号转发、坏二进制、卡死压力测试、L2 防护行为、单元探测 |
| `guard_safety_test.go` | 破坏性路径:回滚不可逆、任务协议往返、failback 插件集 |
| `rollback_irreversible_test.go` | 不可逆性证据 + 二次回滚 no-op + 新建→删除 |

**7 个变异逐个实测被杀**:parseDur 移回原处 / 心跳漏判 / 心跳误判 /
插件集留空 / exitRecovered→crash / exitRecoveryFailed→recovered /
rollback 不删 changeset / L2 防护失效 / 单元探测返回假名。

### 判据自身被变异测试逼着改三次

1. `isSelfRestartCommand` 的测试全绿,但把 `doLastResortGuarded` 改成
   直接调 `doLastResort`(防护完全不生效)时**仍然全绿** ⇒ 补了
   **行为测试**:真子进程 + 假 systemctl(PATH 前置),验「自指时
   restart_command 绝不被执行」
2. 单元探测只断言「非空+格式合法」,被 `return "wrong.service"` 骗过
   ⇒ 补了与**独立第二实现**(测试里重写一遍 cgroup 解析)的交叉核对
3. 信号转发测试用 `sleep 300` 会泄漏孤儿进程(sh fork 子进程,信号只到 sh)
   ⇒ 改 `exec sleep 300`,并加断言检测泄漏(60s → 0.52s)

### 踩到的两个坑(都记在判据注释里)

- **POSIX 环境变量不能含 NUL**:`\x00` 分隔符让 exec 直接报
  "environment variable contains NUL",改用 `\x1f`
- **测试函数里 os.Exit 会终止整个测试二进制** ⇒ 子进程入口必须用 TestMain

全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL。
2026-10-11 10:09:15 +08:00

158 lines
5.0 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.

package tracker
import (
"os"
"path/filepath"
"testing"
)
// TestRollbackFromDiskIsIrreversible 把「agentfs 回滚**不可逆**」钉成事实。
//
// # 为什么这条重要
//
// cmd/homed 的 guard 在 failback 连续耗尽后会调用 rollbackAgentFS,
// 它走的正是 RollbackFromDisk。而该函数做两件事:
//
// 1. 对每个 changeset 逆应用(**删除/还原用户文件**)
// 2. **再把 changeset 记录本身与内容 blob 一并删掉**
//
// 第二步让整个操作**不可逆** —— 没有「反悔」的余地。
//
// 这不是缺陷,是设计(磁盘上的记录用掉了就没了)。
// 但它是用户数据级的破坏性操作,而触发路径是「LLM 连不上三轮」
// 这种完全可能发生的日常故障。所以必须有一条判据把它的行为钉死:
// 一旦将来有人改坏 RollbackFromDisk(比如漏删 blob、或漏了逆应用),
// 这里会立刻红。
//
// 现有 disk_test.go 只断言「回滚后内容正确」,没有断言「记录被清除」——
// 后者才是不可逆性的证据。
func TestRollbackFromDiskIsIrreversible(t *testing.T) {
dataDir := t.TempDir()
workDir := t.TempDir()
trk := NewTracker(dataDir, workDir)
if err := trk.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
os.MkdirAll(trk.upperDir, 0755)
// 「修改」类变更:回滚后应恢复为原始内容
modFile := filepath.Join(trk.upperDir, "user_note.txt")
if err := os.WriteFile(modFile, []byte("改过的内容"), 0644); err != nil {
t.Fatal(err)
}
trk.saveChangeSet(&ChangeSet{
ID: "cs_mod",
Files: []FileChange{{
Path: "user_note.txt",
Type: ChangeFileModified,
Content: []byte("原始内容"),
}},
})
// 前置条件:确实有 changeset 落盘
if got := trk.ChangesetsOnDisk(); got == 0 {
t.Fatal("前置条件失败:changeset 未落盘,回滚将无事可做")
}
n, err := trk.RollbackFromDisk()
if err != nil {
t.Fatalf("RollbackFromDisk: %v", err)
}
if n != 1 {
t.Fatalf("回滚数量=%d, want 1", n)
}
// 断言一:用户文件被还原
got, err := os.ReadFile(modFile)
if err != nil {
t.Fatalf("回滚后文件应存在: %v", err)
}
if string(got) != "原始内容" {
t.Errorf("回滚后内容=%q, want 原始内容", got)
}
// 断言二(不可逆性的证据):changeset 记录已被删除
if left := trk.ChangesetsOnDisk(); left != 0 {
var names []string
if entries, err := os.ReadDir(filepath.Join(dataDir, "changesets")); err == nil {
for _, e := range entries {
names = append(names, e.Name())
}
}
t.Errorf("回滚后磁盘上仍有 %d 个 changeset(%v)—— 记录本应被清除,"+
"否则重复调用会把同一批变更再逆应用一次", left, names)
}
}
// TestRollbackFromDiskTwiceIsNoOp 保证重复回滚是安全的空操作。
//
// 为何要这条:guard 的状态机在「failback 轮次耗尽」后会调
// rollbackAgentFS 然后 continue 到下一轮;若上一轮已经清过记录,
// 第二次调用必须是 no-op,否则会「回滚到一个更早的状态」——
// 而那个状态已经不存在了。
func TestRollbackFromDiskTwiceIsNoOp(t *testing.T) {
dataDir := t.TempDir()
workDir := t.TempDir()
trk := NewTracker(dataDir, workDir)
if err := trk.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
os.MkdirAll(trk.upperDir, 0755)
modFile := filepath.Join(trk.upperDir, "f.txt")
os.WriteFile(modFile, []byte("改过的"), 0644)
trk.saveChangeSet(&ChangeSet{
ID: "cs1",
Files: []FileChange{{Path: "f.txt", Type: ChangeFileModified, Content: []byte("原始")}},
})
if n, err := trk.RollbackFromDisk(); err != nil || n != 1 {
t.Fatalf("首次回滚 n=%d err=%v", n, err)
}
// 第二次:必须是干净的 no-op
n2, err2 := trk.RollbackFromDisk()
if err2 != nil {
t.Errorf("二次回滚报错: %v", err2)
}
if n2 != 0 {
t.Errorf("二次回滚 n=%d,应为 0(记录已被清除,不能重复逆应用)", n2)
}
// 内容必须仍是「原始」,没有被二次破坏
if got, _ := os.ReadFile(modFile); string(got) != "原始" {
t.Errorf("二次回滚后内容=%q, want 原始", got)
}
}
// TestRollbackRestoresDeletedFile 覆盖「新建 → 回滚 = 删除」这条最危险的情形。
//
// agent 写出的文件在回滚后被删掉,这是数据丢失最直接的形式。
func TestRollbackRestoresDeletedFile(t *testing.T) {
dataDir := t.TempDir()
workDir := t.TempDir()
trk := NewTracker(dataDir, workDir)
if err := trk.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
os.MkdirAll(trk.upperDir, 0755)
// 一个「新建」类变更:回滚后该文件应消失
os.WriteFile(filepath.Join(trk.upperDir, "generated.txt"), []byte("生成物"), 0644)
trk.saveChangeSet(&ChangeSet{
ID: "cs_new",
Files: []FileChange{{Path: "generated.txt", Type: ChangeFileCreated}},
})
if _, err := trk.RollbackFromDisk(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(trk.upperDir, "generated.txt")); err == nil {
t.Error("回滚后新建的文件仍存在 —— 回滚没有撤销「新建」")
}
}