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。
This commit is contained in:
JianFeeeee
2026-10-11 10:09:15 +08:00
parent 33a042e4a3
commit a7f89a1b45
6 changed files with 1276 additions and 5 deletions

View File

@ -82,7 +82,7 @@ func applySystemConfig(cfg *guardConfig) {
}
func defaultGuardConfig(dataDir string) *guardConfig {
return &guardConfig{
cfg := &guardConfig{
MaxRestarts: 3,
HeartbeatTimeout: "30s",
HeartbeatInterval: "5s",
@ -97,6 +97,45 @@ func defaultGuardConfig(dataDir string) *guardConfig {
},
LastResortCfg: lastResortConfig{SnapshotBefore: true},
}
// ★ 必须在这里就把 YAML 字符串解析成 time.Duration(2026-10-11 修)。
//
// 原先只在 loadGuardConfig 里 parseDur,而 parseDur 是在 yaml.Unmarshal
// **之后**执行的。问题出在「解析失败」那条早退路径:
//
// if err := yaml.Unmarshal(data, cfg); err != nil {
// log.Printf(...); return cfg ← 直接返回,没走下面的 parseDur
// }
//
// 于是 guard.yaml 一旦写坏(缩进错、半截内容),返回的 cfg 里
// heartbeatTimeout/heartbeatInterval/recovery.attemptTimeout 全是 **0**。
// 而 loadGuardConfig 开头正是 defaultGuardConfig(dataDir),
// 唯一给这些字段兜底的只有 defaultGuardConfig —— 可它也只给了字符串。
//
// 后果不是「配置没生效」而是 **agent 装上就崩**:
// heartbeatStale(path, 0) 里 `time.Since(mtime) > 0` 恒为真,
// 任何心跳都被判陈旧 → guard 每轮都杀 worker → 无限重启。
// attemptTimeout=0 则让恢复 probe 立即超时 → L1 梯子必判失败。
//
// 判据:cmd/homed/guard_config_test.go 的
// TestLoadGuardConfigSurvivesCorruptYAML(配置损坏后仍须正值)。
parseDur := func(s string, def time.Duration) time.Duration {
if s == "" {
return def
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return def
}
return d
}
cfg.heartbeatTimeout = parseDur(cfg.HeartbeatTimeout, 30*time.Second)
cfg.heartbeatInterval = parseDur(cfg.HeartbeatInterval, 5*time.Second)
cfg.rebootGrace = parseDur(cfg.RebootGrace, 10*time.Second)
cfg.Recovery.attemptTimeout = parseDur(cfg.Recovery.AttemptTimeout, 120*time.Second)
if len(cfg.Recovery.Plugins) == 0 {
cfg.Recovery.Plugins = []string{"webui", "pluginmgr", "recoverydiag"}
}
return cfg
}
func loadGuardConfig(dataDir string) *guardConfig {
@ -165,11 +204,20 @@ func runGuard(dataDir string) {
log.Printf("[guard] starting, data=%s max_restarts=%d hb_timeout=%v last_resort=%s failback_rounds=%d",
dataDir, cfg.MaxRestarts, cfg.heartbeatTimeout, cfg.LastResort, cfg.Recovery.MaxAttempts)
// 探测自身所属的 systemd 单元(L2 自杀防护需要)。
// 拿不到就留空 —— 那种情况下跳过自指检测,而不是猜一个单元名。
ownUnit := detectOwnSystemdUnit()
if ownUnit != "" {
log.Printf("[guard] own systemd unit=%s", ownUnit)
} else {
log.Printf("[guard] own systemd unit unknown (L2 self-restart guard disabled)")
}
// 启动即捕获网络基线(L0:写前留档 + L1 还原依据),并给当前 worker 恢复代理环境
captureNetworkBaseline(dataDir)
failures := 0 // 连续 normal 崩溃次数
failbackRound := 0 // 已进行的 failback 轮次
failures := 0 // 连续 normal 崩溃次数
failbackRound := 0 // 已进行的 failback 轮次
failbackDone := false // 本轮 failback 是否已进入
for {
@ -198,7 +246,7 @@ func runGuard(dataDir string) {
outcome, spawnErr := spawnWorkerOnce(exe, dataDir, boot, hbPath, cfg, sigCh)
if spawnErr != nil {
log.Printf("[guard] spawn worker: %v, last_resort=%s", spawnErr, cfg.LastResort)
doLastResort(cfg)
doLastResortGuarded(cfg, ownUnit)
failures = 0
failbackDone = false
failbackRound = 0
@ -239,7 +287,7 @@ func runGuard(dataDir string) {
if cfg.LastResortCfg.SnapshotBefore {
rollbackAgentFS(dataDir)
}
doLastResort(cfg)
doLastResortGuarded(cfg, ownUnit)
failures = 0
failbackDone = false
failbackRound = 0
@ -453,4 +501,118 @@ func doLastResort(cfg *guardConfig) {
}
}
// ══════════════════════════════════════════════════════════════════════
// L2 自杀防护(2026-10-11)
//
// # 要防的是什么
//
// doLastResort(restart_app) 会执行 `systemctl restart <unit>`。而启用 guard 的
// 标准做法正是让 systemd 管理 `homed --role=guard` —— 也就是说 **<unit> 就是
// guard 自己所在的单元**。
//
// 于是 spawnWorker 失败时形成死循环:
//
// guard: doLastResort → systemctl restart homeagent
// → SIGTERM 杀掉自己 → systemd 按 Restart=always 拉起新 guard
// → 新 guard 再 spawn 失败 → 再自杀 ……
//
// 每轮都白跑一遍完整启动,且日志被刷爆。这与 2026-09-05 那次 systemd 审计
// 记录的事故同类(坏配置导致 836 次重启、79 分钟)。
//
// # 正确解法不是「换个单元名」
//
// 换成别的服务(重启 llmsproxy 之类)语义完全不对——L2 的本意是
// 「重启我自己」。而 systemd 的 Restart=already(always) 已经提供了这件事:
//
// guard 自行退出 == systemd 重新拉起 == 重启整个单元
//
// 所以自指时应当**直接退出**,效果与 restart_app 完全等价,且没有自杀窗口。
//
// # 为何还要保留 restart_command
//
// 两种部署形态都合法:
// - systemd 管 guard(推荐):自指 → 退出即可
// - 别的守护器管 guard(如 supervisor/前台 shell):此时 guard 不是
// systemd 单元的 MainPID,restart_command 可能指向一个**正确**的其它单元,
// 或者根本不该执行
// 故不删除,只在「确实是自指」时改走退出路径,并留下明确日志。
func doLastResortGuarded(cfg *guardConfig, unit string) {
if unit != "" && isSelfRestartCommand(cfg.RestartCommand, unit) {
log.Printf("[guard] last_resort=restart_app 但 restart_command 指向自身单元 %q:"+
"执行 systemctl restart 会杀掉自己并被 Restart=always 立即拉起,"+
"形成无限自杀重启。改为**直接退出** —— "+
"在 Restart=always 下这与 restart_app 等价且无自杀窗口。"+
"(若希望 guard 不被 systemd 管,请改用其它 last_resort 或外部守护器)",
unit)
// 交给 systemd 重新拉起(等价于 restart_app)。
// 退出码沿用 workerCleanExit 语义:正常路径下 guard 本就在 worker
// 干净退出时这样返回,systemd 看到非零退出也不会放弃(Restart=always)。
os.Exit(exitGuardRestartRequested)
}
doLastResort(cfg)
}
// isSelfRestartCommand 报告 restart_command 是否会重启 guard 自己所在的单元。
//
// 只认 `systemctl … restart <unit>` 与 `systemctl … restart <unit>.service`
// 两种形态;其它子命令(reload/restart-or-retry 等)不误判。
//
// unit 传空字符串时返回 false(检测不出来就别乱杀)。
func isSelfRestartCommand(cmd []string, unit string) bool {
if len(cmd) == 0 || unit == "" {
return false
}
base := strings.TrimSuffix(unit, ".service")
for i, arg := range cmd {
if arg != "restart" && arg != "restart-or-retry" {
continue
}
// restart 之后的下一个非 flag 参数就是目标单元
for _, target := range cmd[i+1:] {
if strings.HasPrefix(target, "-") {
continue
}
return strings.TrimSuffix(target, ".service") == base
}
}
return false
}
// detectOwnSystemdUnit 返回 guard 自己所属的 systemd 单元名(如 "homeagent.service")。
//
// 两个探测途径(都读 Linux 已有设施,不需额外依赖):
// 1. /proc/self/cgroup —— cgroup v2 下形如 /system.slice/homeagent.service
// 2. $INVOCATION_ID —— 反查 /run/systemd/invocation/<id> 的符号链接名
//
// 都拿不到时返回空字符串(调用方据此跳过自指检测,不猜)。
func detectOwnSystemdUnit() string {
// 途径 1:cgroup
if b, err := os.ReadFile("/proc/self/cgroup"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
// "0::/system.slice/homeagent.service"
idx := strings.Index(line, "::")
if idx < 0 {
continue
}
path := line[idx+2:]
if j := strings.LastIndex(path, "/"); j >= 0 {
name := strings.TrimSpace(path[j+1:])
if strings.HasSuffix(name, ".service") {
return name
}
}
}
}
// 途径 2:INVOCATION_ID → /run/systemd/invocation/<id>
if id := os.Getenv("INVOCATION_ID"); id != "" {
if target, err := os.Readlink("/run/systemd/invocation/" + id); err == nil {
name := filepath.Base(target)
if strings.HasSuffix(name, ".service") {
return name
}
}
}
return ""
}
var _ = fmt.Sprintf

View File

@ -0,0 +1,243 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
// guard 的配置加载与心跳判定 —— 这两块决定了「guard 会不会误判」
// 和「配置坏了会发生什么」,此前**零测试覆盖**。
//
// 为何优先补这两处:它们是纯逻辑、无外部依赖,能立刻把三个真实风险
// 钉成判据(见每个测试的说明)。
// TestLoadGuardConfigDefaults 保证缺配置时有一份**可用**的默认值。
//
// 风险:guard 是「agent 能不能起来」的决策层。若默认值里出现
// 零值(如 MaxRestarts=0 → 永远进 failback、heartbeatTimeout=0
// → 心跳永远判定为 stale → 无限杀 worker),那「没配 guard.yaml」
// 就等于「装上就坏」。
func TestLoadGuardConfigDefaults(t *testing.T) {
dir := t.TempDir() // 空目录 = 无 guard.yaml
cfg := loadGuardConfig(dir)
if cfg.MaxRestarts < 1 {
t.Errorf("MaxRestarts=%d 必须 ≥1,否则第一次崩溃就直接进 failback", cfg.MaxRestarts)
}
if cfg.heartbeatTimeout <= 0 {
t.Error("heartbeatTimeout 必须为正值:0 会让每次心跳检查都判定 stale → 无限杀 worker")
}
if cfg.heartbeatInterval <= 0 {
t.Error("heartbeatInterval 必须为正值")
}
if !cfg.FailbackEnabled {
t.Error("failback 缺省应为启用(这是设计默认)")
}
if cfg.LastResort != "restart_app" && cfg.LastResort != "reboot" {
t.Errorf("LastResort=%q 不是合法值(loadGuardConfig 应归一为 restart_app)", cfg.LastResort)
}
if cfg.Recovery.MaxAttempts < 1 {
t.Errorf("Recovery.MaxAttempts=%d 必须 ≥1,否则 failback 一轮就宣告耗尽", cfg.Recovery.MaxAttempts)
}
if cfg.Recovery.attemptTimeout <= 0 {
t.Error("Recovery.attemptTimeout 必须为正值:0 会让 probe 立即超时 → 梯子必判失败")
}
if len(cfg.Recovery.Plugins) == 0 {
t.Error("缺省 failback 插件集不应为空(空 = 受限启动什么都不加载)")
}
// LLMSnapshot 必须指向 dataDir 下的文件,否则 L1 还原找不到基线。
if cfg.LLMSnapshot == "" {
t.Error("LLMSnapshot 缺省应填 <dataDir>/llm_snapshot.json")
} else if filepath.Dir(cfg.LLMSnapshot) != dir {
t.Errorf("LLMSnapshot=%q 应在 dataDir=%s 下", cfg.LLMSnapshot, dir)
}
}
// TestLoadGuardConfigParsesYAML 保证真实配置能被解析并覆盖默认值。
func TestLoadGuardConfigParsesYAML(t *testing.T) {
dir := t.TempDir()
yaml := `
max_restarts: 5
heartbeat_timeout: 45s
failback_enabled: false
last_resort: reboot
llm:
sources:
- name: rescue
base_url: https://example.invalid/v1
api_key: sk-test
model: some-model
adapter: openai
recovery:
max_attempts: 7
attempt_timeout: 30s
plugins: [webui]
system:
protected_paths: [/custom/]
network_paths: [/custom/resolv.conf]
`
if err := os.WriteFile(filepath.Join(dir, "guard.yaml"), []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
cfg := loadGuardConfig(dir)
if cfg.MaxRestarts != 5 {
t.Errorf("MaxRestarts=%d, want 5", cfg.MaxRestarts)
}
if cfg.heartbeatTimeout != 45*time.Second {
t.Errorf("heartbeatTimeout=%v, want 45s", cfg.heartbeatTimeout)
}
if cfg.FailbackEnabled {
t.Error("FailbackEnabled 被 YAML 覆盖失效")
}
if cfg.LastResort != "reboot" {
t.Errorf("LastResort=%q, want reboot", cfg.LastResort)
}
if cfg.Recovery.MaxAttempts != 7 {
t.Errorf("Recovery.MaxAttempts=%d, want 7", cfg.Recovery.MaxAttempts)
}
if len(cfg.Recovery.Plugins) != 1 || cfg.Recovery.Plugins[0] != "webui" {
t.Errorf("Recovery.Plugins=%v, want [webui]", cfg.Recovery.Plugins)
}
if len(cfg.LLM.Sources) != 1 || cfg.LLM.Sources[0].BaseURL != "https://example.invalid/v1" {
t.Errorf("LLM.Sources 未被 YAML 覆盖: %+v", cfg.LLM.Sources)
}
// 系统路径适配要生效(发行版差异靠它)
if len(cfg.System.ProtectedPaths) != 1 || cfg.System.ProtectedPaths[0] != "/custom/" {
t.Errorf("System.ProtectedPaths=%v", cfg.System.ProtectedPaths)
}
}
// TestLoadGuardConfigSurvivesCorruptYAML 保证**配置写坏不会让 guard 变得不可用**。
//
// 真实风险:guard.yaml 是纯文本,被编辑器截断或手工写错都很常见。
// 若解析失败时返回零值配置(而不是默认值),就会出现
// MaxRestarts=0 → 立刻 failback → heartbeatTimeout=0 → 无限杀 worker。
// loadGuardConfig 当前用「先填默认值再 Unmarshal」规避了这点,本测试钉住它。
func TestLoadGuardConfigSurvivesCorruptYAML(t *testing.T) {
dir := t.TempDir()
// 典型的手工写坏:缩进错误 + 半截内容
broken := "max_restarts: 3\n bad_indent: [\n unclosed: {"
if err := os.WriteFile(filepath.Join(dir, "guard.yaml"), []byte(broken), 0644); err != nil {
t.Fatal(err)
}
cfg := loadGuardConfig(dir)
// 关键:必须退回一份**可用**的配置,而不是零值
if cfg.MaxRestarts < 1 {
t.Errorf("配置损坏后 MaxRestarts=%d,退化成了零值(会导致立刻 failback)", cfg.MaxRestarts)
}
if cfg.heartbeatTimeout <= 0 {
t.Error("配置损坏后 heartbeatTimeout 退化为零值(会导致无限杀 worker)")
}
if cfg.Recovery.MaxAttempts < 1 {
t.Error("配置损坏后 Recovery.MaxAttempts 退化为零值(failback 一轮就耗尽)")
}
if cfg.Recovery.attemptTimeout <= 0 {
t.Error("配置损坏后 attemptTimeout 退化为零值(probe 立即超时 → 梯子必败)")
}
}
// TestLoadGuardConfigRejectsOutOfRangeValues 保证越界值被归一到可用区间。
func TestLoadGuardConfigRejectsOutOfRangeValues(t *testing.T) {
dir := t.TempDir()
yaml := `
max_restarts: 0
heartbeat_timeout: "not-a-duration"
last_resort: bogus
recovery:
max_attempts: -5
attempt_timeout: "also-not-a-duration"
`
if err := os.WriteFile(filepath.Join(dir, "guard.yaml"), []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
cfg := loadGuardConfig(dir)
if cfg.MaxRestarts < 1 {
t.Errorf("max_restarts=0 未被归一:%d", cfg.MaxRestarts)
}
if cfg.heartbeatTimeout <= 0 {
t.Error("无法解析的 heartbeat_timeout 未回落到默认值")
}
if cfg.LastResort == "bogus" {
t.Error("非法 last_resort 未被归一为 restart_app")
}
if cfg.Recovery.MaxAttempts < 1 {
t.Errorf("负数 max_attempts 未被归一:%d", cfg.Recovery.MaxAttempts)
}
if cfg.Recovery.attemptTimeout <= 0 {
t.Error("无法解析的 attempt_timeout 未回落到默认值")
}
}
// TestHeartbeatStale 保证「卡死判定」不会误杀,也不会漏判。
//
// 这是 guard 唯一的存活判据,两种错都致命:
// - **误判 stale**(心跳刚写过却说卡死)→ 杀掉健康的 worker。
// 代价:agent 反复重启,正在进行的对话全丢。
// - **漏判 stale**(心跳早就不动了却说不卡死)→ 卡死的 worker 永不被杀,
// guard 一直等它,agent 实际不可用却没人恢复。
//
// 三个分支都要覆盖:无文件(宽限)/ 新鲜 / 陈旧。
func TestHeartbeatStale(t *testing.T) {
dir := t.TempDir()
hb := filepath.Join(dir, "heartbeat")
// ① 无心跳文件:刚启动或异常,**必须宽限**(否则刚起就杀)
if heartbeatStale(hb, 30*time.Second) {
t.Error("无心跳文件时应判为不陈旧(宽限),否则 worker 刚启动就会被杀")
}
// ② 刚写入:新鲜
if err := os.WriteFile(hb, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if heartbeatStale(hb, 30*time.Second) {
t.Error("心跳刚写入却判为陈旧 —— 会误杀健康 worker")
}
// ③ 陈旧:把 mtime 推到过去
old := time.Now().Add(-2 * time.Minute)
if err := os.Chtimes(hb, old, old); err != nil {
t.Fatal(err)
}
if !heartbeatStale(hb, 30*time.Second) {
t.Error("心跳 2 分钟未更新(阈值 30s)却判为新鲜 —— 卡死的 worker 永远不会被杀")
}
// 阈值边界:刚过阈值一点点就该判陈旧
just := time.Now().Add(-31 * time.Second)
if err := os.Chtimes(hb, just, just); err != nil {
t.Fatal(err)
}
if !heartbeatStale(hb, 30*time.Second) {
t.Error("心跳 31s 未更新(阈值 30s)应判为陈旧")
}
}
// TestSnapshotBeforeDefaultsTrue 记录当前默认行为 —— 该默认值意味着
// 「failback 耗尽时会不可逆地回滚并删除 agentfs 变更」。
//
// 这不是判据,是**显式记录**:一旦有人要改这个默认值,本测试会提醒
// 同时更新文档/部署说明,因为该操作的破坏性是用户数据级的。
//
// 真正的安全性由 TestRollbackAgentFSIsDestructive 这条(下面)保证。
func TestSnapshotBeforeDefaultsTrue(t *testing.T) {
dir := t.TempDir()
cfg := loadGuardConfig(dir)
if !cfg.LastResortCfg.SnapshotBefore {
t.Log("注意:SnapshotBefore 默认为 false(不自动回滚 agentfs)")
}
// 两种取值都不该让 guard 崩或跳过 L2
if !cfg.FailbackEnabled {
t.Error("failback 缺省应启用")
}
}

View File

@ -0,0 +1,531 @@
//go:build linux
package main
import (
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// guard 的 **worker 生命周期**压力测试:spawnWorkerOnce 的退出码语义。
//
// 这块此前零覆盖,而它是 guard 状态机的输入 —— 退出码判错会让
// 「恢复成功」被当成「崩溃」,反之则会让真正的崩溃被当成正常重启。
// 后者更危险:failures 永不累加 ⇒ 永不进 failback ⇒ agent 反复崩溃却没人管。
//
// 这里用真实的子进程(/bin/sh 脚本)模拟各种退出情形,
// 而不是 mock —— 因为退出码→workerOutcome 的映射涉及 exec 包的真实行为,
// mock 掉就测不到「非零退出码能否被正确分类」。
// fakeExe 生成一个可执行脚本:按给定方式退出。
func fakeExe(t *testing.T, name, script string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
t.Fatal(err)
}
return path
}
func testCfg(t *testing.T, dir string) *guardConfig {
t.Helper()
cfg := loadGuardConfig(dir)
// 加速:心跳检查间隔压到 50ms(默认 5s,测试等不起)
cfg.heartbeatInterval = 50 * time.Millisecond
cfg.heartbeatTimeout = 10 * time.Second
return cfg
}
// TestSpawnWorkerOnceClassifiesExitCodes 钉住退出码 → workerOutcome 的映射。
//
// 每个分支错了都会造成实际故障:
// - exitRecovered(43) 认成 crash → 恢复成功了却当失败,failures 累加
// - exitRecoveryFailed(44) 认成 recovered → 恢复失败了却当成功,**永不再恢复**
// - exitRestartRequested(42) 认成 crash → 正常重启被计入崩溃次数
func TestSpawnWorkerOnceClassifiesExitCodes(t *testing.T) {
dataDir := t.TempDir()
hbPath := filepath.Join(dataDir, "heartbeat")
sigCh := make(chan os.Signal, 1)
cfg := testCfg(t, dataDir)
cases := []struct {
name string
script string
want workerOutcome
}{
{"正常退出", "#!/bin/sh\nexit 0\n", workerCleanExit},
{"请求重启", "#!/bin/sh\nexit 42\n", workerRestartRequested},
{"恢复成功", "#!/bin/sh\nexit 43\n", workerRecovered},
{"恢复失败", "#!/bin/sh\nexit 44\n", workerCrash},
{"崩溃(1)", "#!/bin/sh\nexit 1\n", workerCrash},
{"被信号杀死", "#!/bin/sh\nkill -9 $$\n", workerCrash},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
exe := fakeExe(t, "worker.sh", c.script)
got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, sigCh)
if err != nil {
t.Fatalf("spawnWorkerOnce 返回 spawnErr=%v", err)
}
if got != c.want {
t.Errorf("退出码场景 %q → outcome=%v, want %v", c.name, got, c.want)
}
})
}
}
// TestSpawnWorkerOnceCleanExitOnSignal 保证收到 SIGTERM 时 guard 能优雅收尾。
//
// 部署流程(systemctl restart)会发 SIGTERM 给整个 cgroup。
// 若 guard 不把信号转给 worker,worker 会被 SIGKILL 强杀 →
// 正在进行的对话丢失,且留下未落盘的状态。
//
// 注:这里用 `exec sleep` 而不是 `sleep 300` —— 后者会让 sh fork 出子进程,
// guard 的 Signal(SIGTERM) 只发给 sh(它是 cmd.Process),sleep 变孤儿继续跑,
// 每个测试泄漏一个进程(实测会攒出好几个 sleep 300)。
// exec 让 sleep **替换** sh 进程,信号直达,退出干净。
func TestSpawnWorkerOnceCleanExitOnSignal(t *testing.T) {
dataDir := t.TempDir()
hbPath := filepath.Join(dataDir, "heartbeat")
cfg := testCfg(t, dataDir)
// exec sleep 300:sh 被 sleep 替换,SIGTERM 直达并退出
exe := fakeExe(t, "sleeper.sh", "#!/bin/sh\nexec sleep 300\n")
sigCh := make(chan os.Signal, 1)
result := make(chan workerOutcome, 1)
go func() {
got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, sigCh)
if err != nil {
t.Errorf("spawnErr=%v", err)
}
result <- got
}()
// 等 worker 确实起来了再发信号
time.Sleep(300 * time.Millisecond)
sigCh <- syscall.SIGTERM
select {
case got := <-result:
if got != workerCleanExit {
t.Errorf("收到信号后 outcome=%v, want workerCleanExit", got)
}
case <-time.After(15 * time.Second):
t.Fatal("guard 收到信号后没有在 15s 内返回(信号未转发给 worker?)")
}
// 确认没有留下孤儿进程(本次修复的核心)
time.Sleep(200 * time.Millisecond)
if n := countOrphanSleepers(); n > 0 {
t.Errorf("遗留了 %d 个 sleep 进程 —— 测试脚本应用 exec 让信号直达", n)
}
}
// countOrphanSleepers 统计当前系统里的 sleep 进程数(用于检测测试泄漏)。
func countOrphanSleepers() int {
out, err := exec.Command("sh", "-c", "ps -eo args | grep -c '^sleep 300$'").Output()
if err != nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
return n
}
// TestSpawnWorkerOnceFailsOnBadBinary 保证「拉不起来」返回 spawnErr 而非崩溃。
//
// 这条路径会触发 doLastResort —— 而默认的 restart_command 指向
// homeagent 单元,若 systemd 管的正是 guard 就会自杀循环。
// 至少先保证「拉不起来」被正确报告,而不是静默当成 crash 继续循环。
func TestSpawnWorkerOnceFailsOnBadBinary(t *testing.T) {
dataDir := t.TempDir()
cfg := testCfg(t, dataDir)
sigCh := make(chan os.Signal, 1)
// 不存在的路径
got, err := spawnWorkerOnce("/nonexistent/homed", dataDir, "normal",
filepath.Join(dataDir, "heartbeat"), cfg, sigCh)
if err == nil {
t.Errorf("二进制不存在时应返回 spawnErr,实际 outcome=%v err=nil", got)
}
}
// TestSpawnWorkerOnceKillsHungWorker 覆盖「心跳陈旧 → SIGKILL 卡死 worker」。
//
// 压力测试要点:worker **进程还活着**但不响应(模拟死锁/IO 挂起)。
// 只看进程存活会永远等它,必须靠心跳/IPC 判定并杀掉。
func TestSpawnWorkerOnceKillsHungWorker(t *testing.T) {
if os.Getenv("GUARD_STRESS") == "" {
t.Skip("压力测试:需要 GUARD_STRESS=1(会真的起进程并杀它)")
}
dataDir := t.TempDir()
hbPath := filepath.Join(dataDir, "heartbeat")
cfg := testCfg(t, dataDir)
cfg.heartbeatTimeout = 200 * time.Millisecond
// worker 起来后不写心跳也不响应 IPC —— 模拟卡死
exe := fakeExe(t, "hang.sh", "#!/bin/sh\ntrap '' TERM\nwhile true; do sleep 1; done\n")
// 造一个陈旧心跳(IPC 不通时会回退到文件判定)
old := time.Now().Add(-time.Hour)
if err := os.WriteFile(hbPath, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(hbPath, old, old); err != nil {
t.Fatal(err)
}
// 必须在超时内返回:若心跳判定失效,spawnWorkerOnce 会一直等 worker
// 自然退出(而卡死 worker 永远不会退出)⇒ 测试会挂到 go test 超时。
// 所以这里给它一个硬上限,超出即判失败并报清楚原因,而不是让整个包卡死。
type res struct {
outcome workerOutcome
err error
}
result := make(chan res, 1)
go func() {
got, err := spawnWorkerOnce(exe, dataDir, "normal", hbPath, cfg, make(chan os.Signal, 1))
result <- res{got, err}
}()
start := time.Now()
select {
case r := <-result:
elapsed := time.Since(start)
if r.err != nil {
t.Fatalf("spawnErr=%v", r.err)
}
if r.outcome != workerCrash {
t.Errorf("卡死 worker 的 outcome=%v, want workerCrash", r.outcome)
}
// 阈值心跳是 200ms,留足余量但仍能抓住「退化成等自然退出」
if elapsed > 10*time.Second {
t.Errorf("杀掉卡死 worker 用了 %v,太慢(心跳判定可能失效)", elapsed)
}
t.Logf("卡死 worker 在 %v 内被判定并杀掉", elapsed)
case <-time.After(20 * time.Second):
// 主动杀掉残留进程,否则它会一直跑到测试二进制退出
pkillPattern("hang.sh")
t.Fatal("20s 内未判定卡死 worker —— 心跳/IPC 存活判定失效了")
}
}
// TestExitCodeConstantsAreDistinct 保证退出码不冲突。
//
// 它们要与 worker_exit.go 里的实际取值一致;一旦有人改了其中之一而
// 另一处没跟上,spawnWorkerOnce 的 switch 会静默走到 default(判成 crash)。
func TestExitCodeConstantsAreDistinct(t *testing.T) {
all := map[int]string{
exitRestartRequested: "exitRestartRequested",
exitRecovered: "exitRecovered",
exitRecoveryFailed: "exitRecoveryFailed",
}
if len(all) != 3 {
t.Fatalf("退出码数量=%d,应为 3(值冲突:%v)", len(all), all)
}
seen := map[int]string{}
for code, name := range all {
if prev, dup := seen[code]; dup {
t.Errorf("退出码 %d 同时用于 %s 与 %s —— switch 会先匹配到谁?", code, prev, name)
}
seen[code] = name
if code <= 0 || code > 125 {
t.Errorf("%s = %d,超出 shell 约定的 1-125 范围", name, code)
}
}
}
// TestDetectOwnSystemdUnit 验证能识别出自己所属单元(拿不到时返回空)。
//
// 自指检测的前提是「知道自己是谁」。拿不到时必须返回空字符串,
// 让调用方跳过检测 —— 而不能猜一个单元名(猜错 = 拦错/杀错服务)。
func TestDetectOwnSystemdUnit(t *testing.T) {
unit := detectOwnSystemdUnit()
if unit == "" {
t.Log("未检测到 systemd 单元(非 systemd 环境,跳过)")
return
}
if !strings.HasSuffix(unit, ".service") {
t.Errorf("检测到单元名 %q,应以 .service 结尾", unit)
}
if strings.ContainsAny(unit, "/ \t") {
t.Errorf("单元名 %q 含非法字符", unit)
}
// ★ 必须在 systemd 环境里真的探测到**本进程所属**的单元。
//
// 只断言「非空 + 格式合法」会被「返回一个写死的假名字」骗过
// (变异测试实测:detectOwnSystemdUnit 直接 return "wrong.service"
// 时所有测试仍然全绿)。
//
// 而「探测错单元」的后果很具体:自指防护会拿错误的 unit 去比对
// restart_command,于是「本该拦下的自杀重启」被放行。
if !strings.Contains(unit, "homeagent") {
t.Logf("检测到单元 %q —— 本进程应属于 homeagent.service。"+
"若这是 CI/容器环境则属正常;若是生产则说明探测逻辑错了", unit)
}
// 与 cgroup 的事实交叉核对(不依赖 detectOwnSystemdUnit 自己的实现)
if own, err := ownUnitFromCgroup(); err == nil && own != "" {
if own != unit {
t.Errorf("单元探测结果=%q,但 /proc/self/cgroup 显示=%q,两者不一致", unit, own)
} else {
t.Logf("单元探测与 cgroup 交叉核对一致: %s", unit)
}
}
}
// ownUnitFromCgroup 是独立于 detectOwnSystemdUnit 的第二实现,用于交叉核对。
// (测试里刻意重写一遍,而不是复用生产函数——复用就等于自己验自己。)
func ownUnitFromCgroup() (string, error) {
b, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return "", err
}
for _, line := range strings.Split(string(b), "\n") {
idx := strings.Index(line, "::")
if idx < 0 {
continue
}
p := line[idx+2:]
j := strings.LastIndex(p, "/")
if j < 0 {
continue
}
name := strings.TrimSpace(p[j+1:])
if strings.HasSuffix(name, ".service") {
return name, nil
}
}
return "", nil
}
// TestSelfRestartDetection 覆盖自指检测的各形态(2026-10-11 加的真实防护)。
func TestSelfRestartDetection(t *testing.T) {
if !isSelfRestartCommand([]string{"systemctl", "restart", "homeagent"}, "homeagent.service") {
t.Error("未能识别 systemctl restart homeagent(guard 所在单元)为自指")
}
if !isSelfRestartCommand([]string{"systemctl", "restart", "homeagent.service"}, "homeagent.service") {
t.Error("带 .service 后缀的自指命令未识别")
}
if isSelfRestartCommand([]string{"systemctl", "restart", "llmsproxy"}, "homeagent.service") {
t.Error("误判 systemctl restart llmsproxy 为自指")
}
if isSelfRestartCommand([]string{"/usr/bin/my-script"}, "homeagent.service") {
t.Error("非 systemctl 形态不应判为自指")
}
if isSelfRestartCommand(nil, "homeagent.service") {
t.Error("空命令不应判为自指")
}
if isSelfRestartCommand([]string{"systemctl", "restart", "homeagent"}, "") {
t.Error("单元名未知时不应判为自指(宁可不拦,也不要误杀)")
}
if isSelfRestartCommand([]string{"systemctl", "reload", "homeagent"}, "homeagent.service") {
t.Error("reload 不应被当作 restart 自指")
}
if !isSelfRestartCommand([]string{"systemctl", "--user", "restart", "homeagent"}, "homeagent.service") {
t.Error("带全局选项(--user)的 restart 未识别为自指")
}
}
// TestGuardExitCodeDoesNotCollideWithWorker 保证 guard 自身退出码不与 worker 协议冲突。
func TestGuardExitCodeDoesNotCollideWithWorker(t *testing.T) {
guard := exitGuardRestartRequested
for _, w := range []int{exitRestartRequested, exitRecovered, exitRecoveryFailed} {
if guard == w {
t.Errorf("guard 退出码 %d 与 worker 退出码冲突", guard)
}
}
if guard == 0 {
t.Error("guard 退出码不能是 0(会被当成正常退出)")
}
}
// pkillPattern 杀掉匹配指定脚本名的残留进程(测试失败时的清理)。
func pkillPattern(name string) {
_ = exec.Command("pkill", "-f", name).Run()
}
// ══════════════════════════════════════════════════════════════════════
// L2 自杀防护的**行为**测试(不只测判定函数)
//
// 前面那些只证明 isSelfRestartCommand 能识别自指,
// 但变异测试暴露过一个缺口:把 doLastResortGuarded 改成直接调 doLastRestort
// (防护完全不生效)时,判定函数的测试仍然全绿。
//
// 所以这里用**真实子进程**验证行为:真的执行 doLastResortGuarded,
// 看它到底会不会执行 restart_command。
//
// 手法:把 RestartCommand 换成一个会写标记文件的命令。
// - 非自指 → 应执行该命令 → 标记文件出现
// - 自指 → 应改为 os.Exit(41) → 标记文件**不出现** + 退出码 41
// (用一个带 --self-exit 开关的测试子进程来跑,避免真的退出测试进程)
// ══════════════════════════════════════════════════════════════════════
// l2Sep 是把 restart_command 各参数传给子进程的分隔符。
//
// ★ 为何不用 "\x00":POSIX 环境变量里不能含 NUL 字节,
//
// exec 会直接报 "environment variable contains NUL"(实测踩过)。
// 用 \x1f(单元分隔符)—— 命令参数里几乎不会出现,且合法。
const l2Sep = "\x1f"
// TestDoLastResortGuardedExitsOnSelfReference 是本组最关键的判据。
func TestDoLastResortGuardedExitsOnSelfReference(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "restart-command-ran")
// ★ restart_command 必须保持 `systemctl restart <unit>` 形态:
// isSelfRestartCommand 是按「restart 动词 + 其后的单元名」判定的,
// 换成 `touch <file>` 就根本不是 systemctl 命令,防护按设计不会触发
// (那是「命令形态不匹配」,不是「自指」)。
// 而「执行 touch」又要求它真能产生可观察副作用,所以用
// `systemctl restart <unit>` 无法在测试里真的执行(会动真实服务)。
// 折中:仍用 systemctl 形态的单元名,但把可执行体换成一个
// 「无害的假 systemctl」——通过 PATH 前置一个临时目录实现。
cfg := &guardConfig{
LastResort: "restart_app",
RestartCommand: []string{"systemctl", "restart", "homeagent"},
}
binDir := fakeSystemctlDir(t, dir, marker)
// 自指:restart_command 指向 homeagent(guard 自己的单元)
out, code := runInSubprocess(t, "selfref", dir, cfg, "homeagent.service", binDir)
// ★ 关键断言:restart_command 绝不能被执行
if _, err := os.Stat(marker); err == nil {
t.Fatalf("★ 自指时 restart_command 仍被执行了(自杀防护失效)")
}
if code != exitGuardRestartRequested {
t.Errorf("自指时应以退出码 %d 退出(交给 systemd 重新拉起),实为 %d",
exitGuardRestartRequested, code)
}
t.Logf("子进程输出:\n%s", out)
t.Logf("自指时正确退出(code=%d),restart_command 未被执行", code)
}
// TestDoLastResortGuardedRunsForNonSelfReference 反向判据:
// 非自指时**必须**照常执行 restart_command —— 否则防护把正常恢复也堵死了。
func TestDoLastResortGuardedRunsForNonSelfReference(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "restart-command-ran")
cfg := &guardConfig{
LastResort: "restart_app",
RestartCommand: []string{"systemctl", "restart", "homeagent"},
}
binDir := fakeSystemctlDir(t, dir, marker)
out, code := runInSubprocess(t, "nonself", dir, cfg, "some-other.service", binDir)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("非自指时 restart_command 应被执行(防护过度拦截)")
}
if code == exitGuardRestartRequested {
t.Errorf("非自指时不应走 os.Exit 路径(实为 code=%d)", code)
}
if strings.Contains(out, "自身单元") {
t.Errorf("非自指时不应打印自指告警:\n%s", out)
}
t.Log("非自指时照常执行 restart-command(防护未误伤)")
}
// runInSubprocess 在子进程里跑 doLastResortGuarded,返回 (合并输出, 退出码)。
func runInSubprocess(t *testing.T, tag, dir string, cfg *guardConfig, unit string, binDir string) (string, int) {
t.Helper()
outFile := filepath.Join(dir, "out-"+tag+".txt")
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(exe,
"-test.run=TestDoLastResortGuardedExitsOnSelfReference",
"-test.v")
cmd.Env = append(os.Environ(),
"GUARD_L2_PROBE=1",
"L2_PROBE_UNIT="+unit,
"L2_PROBE_RESTART="+strings.Join(cfg.RestartCommand, l2Sep),
"L2_PROBE_OUT="+outFile,
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
)
b, _ := cmd.CombinedOutput()
code := cmd.ProcessState.ExitCode()
out := string(b)
if f, err := os.ReadFile(outFile); err == nil {
out += string(f)
}
return out, code
}
// TestMain 在子进程模式下(GUARD_L2_PROBE=1)执行 L2 逻辑后退出,
// 根本不进入任何测试。
//
// ★ 为何必须用 TestMain 而不是「在测试函数开头判断环境变量后 os.Exit」:
//
// 测试函数里 os.Exit 会终止整个测试二进制——父进程想看的退出码根本没机会产生,
// 而且会把「父进程把子进程的重定向输出读回来」这条链路也一并打断。
// TestMain 才是「子进程不做测试、直接干活并退出」的正确入口。
func TestMain(m *testing.M) {
if os.Getenv("GUARD_L2_PROBE") != "" {
runL2ProbeChild()
return
}
os.Exit(m.Run())
}
// runL2ProbeChild 是子进程里的实际执行体(由 TestMain 在 GUARD_L2_PROBE 时调用)。
func runL2ProbeChild() {
unit := os.Getenv("L2_PROBE_UNIT")
outFile := os.Getenv("L2_PROBE_OUT")
restart := os.Getenv("L2_PROBE_RESTART")
if restart == "" || outFile == "" {
os.Exit(90)
}
cfg := &guardConfig{
LastResort: "restart_app",
RestartCommand: strings.Split(restart, l2Sep),
}
// log 输出重定向到文件,便于父进程断言
if f, err := os.Create(outFile); err == nil {
old := os.Stdout
os.Stdout = f
defer func() { os.Stdout = old; f.Close() }()
log.SetOutput(f)
}
doLastResortGuarded(cfg, unit)
// 走到这里说明没有 os.Exit —— 记录一下
os.Exit(0)
}
// fakeSystemctlDir 造一个「假 systemctl」放到临时 PATH 最前面:
// 它被调用时写出 marker 文件,然后正常退出(exit 0)。
//
// ★ 为何需要它:L2 自指防护的判定依据是 restart_command 里
//
// 「restart 动词 + 单元名」这两个形态特征(isSelfRestartCommand)。
// 若测试里用 `touch <marker>` 之类,命令形态根本不匹配 systemctl,
// 防护按设计不会触发 —— 测的就不是自指防护了。
// 而真跑 `systemctl restart homeagent` 会**真的重启生产服务**。
// 故用 PATH 前置一个假 systemctl:形态对、副作用可观察、绝不动真服务。
func fakeSystemctlDir(t *testing.T, dir, marker string) string {
t.Helper()
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\ntouch \"" + marker + "\"\nexit 0\n"
if err := os.WriteFile(filepath.Join(binDir, "systemctl"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
return binDir
}

View File

@ -0,0 +1,171 @@
//go:build linux
package main
import (
"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)
}
}
}

View File

@ -12,6 +12,13 @@ const (
exitRestartRequested = 42 // worker 清理后请求重建(不计失败轮次)
exitRecovered = 43 // failback worker 恢复成功,guard 应重置失败轮次并交回主 agent
exitRecoveryFailed = 44 // failback worker 单轮恢复失败,guard 计入失败并进入下一轮/最后手段
// exitGuardRestartRequested 是 guard 自身的退出码:L2 需要「重启本单元」但
// restart_command 指向自己所在单元时(systemctl restart 会自杀),
// guard 改为直接退出,由 systemd 的 Restart=always 重新拉起 —— 等价且无自杀窗口。
//
// 刻意取 41(小于 worker 的 42-44 区间),避免与 worker 退出码协议混淆。
exitGuardRestartRequested = 41
)
// resolveDataDir 复用原有的数据目录探测逻辑。

View File

@ -0,0 +1,157 @@
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("回滚后新建的文件仍存在 —— 回滚没有撤销「新建」")
}
}