Files
HomeAgent/cmd/homed/heartbeat_cmd_test.go
JianFeeeee 198811253a feat(webui+guard): 内核重启/关机按钮,指令走心跳复用既有协议
## 需求

WebUI 加关机与重启按钮,内核提供对应内部接口。有些状态变更只能靠重启
生效(换 LLM 源、换主密钥),没有这条通道时用户只能 ssh 上去敲 systemctl。

## 设计:复用心跳,不新开通道(用户指出的方向)

guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件,
内容原本是 `t=<unix>`,只用来刷 mtime)。用户点按钮本质就是"我要你在
下一次心跳时知道这件事"——再开 unix socket / 信号 / 临时文件,只会多出
一套要防重、防丢、防竞态的机制。

    WebUI 点按钮
      → POST /api/v1/kernel/{restart,shutdown}
      → sdk.KernelControlAPI(只投递,立即返回)
      → 下一次心跳把 `cmd=restart|shutdown reason=...` 写进 <data>/heartbeat
      → 重启:worker 清理 → exit 42 → guard 重拉(**既有机制,零改动**)
        关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
              → systemctl stop → 不再拉起

★ 关机为什么必须由 guard 做:
  unit 是 Restart=always,guard 自己 return 会被 systemd 立刻拉起
  (用户点了"关机"、几秒后服务又活了)。必须显式 systemctl stop。
  而不让 worker 自己停:它是被守护方,且**只有 guard 知道这次该不该重拉**。

★ 指令必须一次性消费:guard 读完立即原子重写文件清除 cmd 行,
  否则每次心跳都读到同一条 ⇒ 无限重启/关机。worker 侧还要检查
  "指令写过但文件里没了" ⇒ 已被消费 ⇒ 清本地 pending,否则又写回去。

## 顺带修的真缺陷:心跳文件无限增长

生产实测 **14.6MB**(纯 O_APPEND,约 70 天)。心跳的唯一职责是刷 mtime,
历史行没有任何读者 ⇒ 每次 consume 要整读 14.6MB(实测 27ms)。
改为 O_TRUNC 滚动写:只保留当前一行。真机验证 15,322,164 → 13 字节。
consume 也加了尺寸守卫(大文件只读尾部 8KB)。

## 安全:能力分级 + 白名单(两道)

1. proc 桥的 method 能力表:新增 `CapKernelControl` 一档。
   ★ 判据 `TestCapability_AllMethodsClassified` 当场报出
   "未登记 ⇒ 会按 CapCore 放行,绕过权限检查" —— 那意味着任何插件都能关机。
2. 内置插件走 `SDKConfig.KernelControl`,第一版我写成"有就发",
   等于全部内置插件都能关机。改为按名字白名单(目前只有 webui)。

## 判据(7 条)

心跳:解析 / 消费后必须清除 / worker 不得写回已消费指令 / 未消费时必须续写
能力:只有白名单插件拿得到 / 未装配时返回 nil 不 panic / SDK nil 安全
心跳文件:不得无限增长 / 大文件只读尾部且读后收敛

变异 6/6 咬住。全量 53 包 ok。

## UI

导航底栏两个图标按钮(与主题/语言/退出并列):重启有确认框 + 轮询等它
回来并自动刷新;关机有**二次确认且写清后果**(必须人工 systemctl start)。
两者都先把响应写回浏览器并 Flush,再让内核退出 —— 否则用户看到的是
connection reset,无法区分"正在重启"和"真出故障了"。
2026-10-17 21:18:07 +08:00

209 lines
7.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.

package main
// 心跳控制指令的判据(2026-10-17)。
//
// ## 为什么这两条判据必须存在
//
// 指令通道有三个**静默失效**点,每一个都表现为"按钮点了没反应":
//
// 1. 解析不到 → guard 不知道用户想关机 → 照常重拉(用户以为没生效)
// 2. **消费后不清除** → guard 每次心跳都读到同一条 → 无限重启/关机
// 3. 清除了但 worker 内存里还留着 → 下一次心跳又写回去 → 同上
//
// 第 2、3 点是同一个 bug 的两面,而且**本机测不出来**(只有真跑才会
// 陷入重启循环)。所以用判据把它们钉住。
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// ★★★ 判据:能解析出合法指令,畸形行被忽略。
func TestParseHeartbeatCmd(t *testing.T) {
cases := []struct {
name string
in string
want hbCommand
}{
{"关机", "t=100\ncmd=shutdown reason=用户点击\n", hbCommand{Cmd: "shutdown", Reason: "用户点击", OK: true}},
{"重启", "t=100\ncmd=restart reason=升级配置\n", hbCommand{Cmd: "restart", Reason: "升级配置", OK: true}},
{"无指令", "t=100\nt=101\n", hbCommand{}},
{"未知指令", "t=100\ncmd=explode\n", hbCommand{}},
{"畸形行", "garbage\ncmd=\n", hbCommand{}},
{"无 reason", "cmd=shutdown\n", hbCommand{Cmd: "shutdown", OK: true}},
{"空内容", "", hbCommand{}},
}
for _, c := range cases {
got := parseHeartbeatCmd(c.in)
if got.Cmd != c.want.Cmd || got.OK != c.want.OK {
t.Errorf("%s: 得到 %+v,应以 %+v", c.name, got, c.want)
}
if c.want.Reason != "" && got.Reason != c.want.Reason {
t.Errorf("%s: reason 得到 %q,应 %q", c.name, got.Reason, c.want.Reason)
}
}
}
// ★★★ 判据:consumeHeartbeatCmd 读完必须**清除** cmd 行(否则无限重启)。
func TestConsumeHeartbeatCmdClearsCommand(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "heartbeat")
if err := os.WriteFile(p, []byte("t=100\ncmd=shutdown reason=x\n"), 0644); err != nil {
t.Fatal(err)
}
c := consumeHeartbeatCmd(p)
if !c.OK || c.Cmd != hbCmdShutdown {
t.Fatalf("未读到关机指令: %+v", c)
}
// 文件必须已被清除
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if parseHeartbeatCmd(string(b)).OK {
t.Fatalf("★ 消费后 cmd 行仍在(%q)—— guard 每次心跳都会读到同一条 ⇒ 无限关机", string(b))
}
// mtime 语义必须保留:文件里仍要有 t= 行,否则 guard 可能判 worker 卡死
if len(b) == 0 {
t.Fatal("★ 清空成空文件 —— 心跳文件的 mtime 语义被破坏")
}
// 第二次消费必须读不到(幂等)
if parseHeartbeatCmd(readAll(t, p)).OK {
t.Fatal("★ 第二次仍能读到指令")
}
}
// ★★★ 判据:worker 侧写过的指令,在 guard 清除后**不该被再写回**。
//
// 这是最容易漏的一面:guard 与 worker 是两个进程,
// guard 清的是**文件**,worker 内存里的 pendingCmd 还在。
// 若 worker 不检查文件状态,下一次 tick 就把指令写回去 ⇒ 无限循环。
func TestWorkerDoesNotRewriteConsumedCommand(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "heartbeat")
// 模拟:已经没有 cmd 行(guard 消费过)
if err := os.WriteFile(p, []byte("t=200\n"), 0644); err != nil {
t.Fatal(err)
}
// 投递一条指令,并标记"已经写过一次"
requestHeartbeatCmd(hbCmdShutdown, "test")
// ★ 用 defer 而不是 t.Cleanup:Cleanup 里的手动 Lock 一旦前面的
// t.Fatalf 提前返回就可能与未释放的锁叠加(静态分析也提示了这一点)。
// defer 的语义更直白:本函数怎么退出都会解锁并复位。
defer resetPendingCmdForTest()
pendingCmdMu.Lock()
pendingWritten = true
pendingCmdMu.Unlock()
content := nextHeartbeatWrite(p, 300)
if content != "" {
t.Fatalf("★ guard 已消费(文件无 cmd 行),worker 却又写回指令:%q\n"+
" 后果:无限关机/重启循环 —— 本机单测测不出,只有真跑才暴露", content)
}
}
// ★★★ 判据:指令未被消费时必须持续写在心跳里(不能写完一次就丢)。
func TestWorkerKeepsWritingUntilConsumed(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "heartbeat")
os.WriteFile(p, []byte("t=100\ncmd=shutdown reason=test\n"), 0644)
requestHeartbeatCmd(hbCmdShutdown, "test")
defer resetPendingCmdForTest()
// 第一次:文件里还有 cmd= ⇒ 继续写(还没被消费)
c1 := nextHeartbeatWrite(p, 200)
if c1 == "" {
t.Fatal("★ 指令还在文件里(未消费)却不继续写 —— guard 可能错过它")
}
}
func readAll(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// resetPendingCmdForTest 复位 pending 状态,保证测试之间不串。
func resetPendingCmdForTest() {
pendingCmdMu.Lock()
pendingCmd = hbCommand{}
pendingWritten = false
pendingCmdMu.Unlock()
}
// ★★★ 判据:心跳文件必须**滚动**,不能无限增长。
//
// 2026-10-17 生产实测:心跳文件长到 **14.6MB**(纯 O_APPEND,约 70 天)。
// 心跳的唯一职责是刷 mtime,历史行没有任何读者 ⇒ 累加纯属浪费,
// 且 consumeHeartbeatCmd 会被迫整读 14.6MB。
func TestHeartbeatFileDoesNotGrowUnbounded(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "heartbeat")
// 模拟 worker 的滚动写:连续写 200 次(每次都是"当前这一行")
for i := 0; i < 200; i++ {
content := "t=" + itoa(int64(1000+i)) + "\n"
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
// 200 次心跳后文件仍应是单行(约 10 字节),不是 200 行
if fi.Size() > 64 {
t.Fatalf("★ 心跳文件增长到 %d 字节 —— 必须是滚动写入(只保留当前行)\n"+
" 生产实测这种累加会到 14.6MB", fi.Size())
}
lines := strings.Count(readAll(t, p), "\n")
if lines != 1 {
t.Fatalf("★ 心跳文件有 %d 行,应为 1 行", lines)
}
}
// ★★★ 判据:巨型历史心跳文件只读尾部(不整读),且读后收敛成小文件。
func TestConsumeLargeHeartbeatReadsTailOnly(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "heartbeat")
// 造一个 1MB 的历史文件,末尾追加指令
var sb strings.Builder
for i := 0; i < 100000; i++ {
sb.WriteString("t=" + itoa(int64(i)) + "\n")
}
sb.WriteString("t=999999\ncmd=shutdown reason=bigfile\n")
if err := os.WriteFile(p, []byte(sb.String()), 0644); err != nil {
t.Fatal(err)
}
c := consumeHeartbeatCmd(p)
if !c.OK || c.Cmd != hbCmdShutdown {
t.Fatalf("★ 大文件里的指令没读到: %+v", c)
}
// 读后必须收敛(clearHeartbeatCmd 重写成单行)
fi, _ := os.Stat(p)
if fi.Size() > 64 {
t.Fatalf("★ 消费后文件仍 %d 字节 —— 巨型历史文件应被一次性收敛", fi.Size())
}
// mtime 语义保留:文件里仍有 t= 行
if !strings.HasPrefix(readAll(t, p), "t=") {
t.Fatal("★ 收敛后丢了 t= 行 —— guard 的 mtime 语义会被破坏")
}
}
func itoa(n int64) string {
return strconv.FormatInt(n, 10)
}