diff --git a/.gitignore b/.gitignore index d6a8b9f1..69f46e35 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,6 @@ dist/ # ── 本地 AI 工具的仓库级配置(每人偏好不同,不进仓库)── .pi-lens.json + +# agent 会话附件(本地产物,不进版本库) +.pi-web/ diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index ba6972d6..0a612319 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -227,7 +227,30 @@ func startHeartbeat(dataDir string, ctx context.Context) func() { // 心跳:每 5s 触碰 /heartbeat,guard 据此判定工作进程是否存活/卡死 hbPath := filepath.Join(dataDir, "heartbeat") writeHB := func() { - if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + // ★ 若有待发的控制指令,**整文件重写**(保留 t= 行 + cmd= 行)。 + // + // 为什么不能继续 append:guard 消费指令后会重写文件清除 cmd 行; + // 若这里仍 append,历史 cmd 行会越积越多(每次读到的还是第一条)。 + // 无指令时保持原来的 append 语义(历史行为不变,只刷 mtime)。 + if content := nextHeartbeatWrite(hbPath, time.Now().Unix()); content != "" { + if err := os.WriteFile(hbPath, []byte(content), 0644); err == nil { + return + } + } + // ★ 心跳文件必须**滚动**,不能无限 append(2026-10-17 实测:14.6MB)。 + // + // heartbeat 的唯一职责是让 guard 看到 mtime 变新(见下面的注释)。 + // 历史行**没有任何读者** —— 唯一读内容的是 consumeHeartbeatCmd, + // 而它只关心 cmd= 行。所以累加历史行纯属浪费: + // · 每 5s 12 字节 ⇒ 约 70 天长到 14.6MB(生产实测就是这个量级) + // · consumeHeartbeatCmd 要 ReadFile 整个文件 ⇒ 白白读 14.6MB + // · 长期跑下去还会一直涨(磁盘与读取成本都随天数线性增长) + // + // 改成截断重写:只保留**当前**这一行。mtime 语义完全不变 + // (文件仍在每次心跳被写),而体积恒定在十几字节。 + // + // 用 O_TRUNC 而不是 O_APPEND:内容不再需要累积。 + if f, err := os.OpenFile(hbPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err == nil { fmt.Fprintf(f, "t=%d\n", time.Now().Unix()) f.Close() } diff --git a/cmd/homed/guard.go b/cmd/homed/guard.go index 6420e373..36ff75ce 100644 --- a/cmd/homed/guard.go +++ b/cmd/homed/guard.go @@ -256,7 +256,25 @@ func runGuard(dataDir string) { switch outcome { case workerCleanExit: + // ★ 先看 worker 有没有留下控制指令(用户点了关机)。 + // + // 这条指令来自心跳文件:worker 在退出前最后几次心跳里写了 + // `cmd=shutdown`。此时 worker 已经退干净,由 guard 决定 + // "这次退出去该不该被 systemd 重新拉起"。 + // + // 为什么要读心跳而不是让 worker 直接 systemctl stop: + // · worker 是被守护方,自己停服务等于在父进程眼皮底下自杀 + // · 只有 guard 知道防护上下文(unit 名、当前是否 failback 中) + // · 决策集中在一处,不散在两个进程里 + if c := consumeHeartbeatCmd(hbPath); c.OK && c.Cmd == hbCmdShutdown { + log.Printf("[guard] worker 已退出且留下关机指令(%s)—— 执行 systemctl stop,不再拉起", + c.Reason) + stopOwnUnit(ownUnit) + return + } log.Printf("[guard] worker exited cleanly, guard exiting") + // ★ 注意:这里 `return` 后 guard 退出,systemd(Restart=always) + // 会重新拉起整个 unit —— 这正是"重启"的既有语义,保持不动。 return case workerRestartRequested: log.Printf("[guard] worker requested restart, respawning") diff --git a/cmd/homed/heartbeat_cmd.go b/cmd/homed/heartbeat_cmd.go new file mode 100644 index 00000000..a38efbb9 --- /dev/null +++ b/cmd/homed/heartbeat_cmd.go @@ -0,0 +1,253 @@ +package main + +// 心跳里的**控制指令**:worker → guard 的唯一意图通道。 +// +// ## 为什么走心跳,而不是另开一条通道 +// +// guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件)。 +// 用户点"重启/关机"本质就是"我要你在下一次心跳时知道这件事"—— +// 开第二条通道(unix socket / 信号 / 临时文件)只会多一套要防重、防丢、 +// 防竞态的机制,而这条已经存在且有 mtime 存活语义。 +// +// ## 格式演进(必须向后兼容) +// +// 原格式每行 `t=\n`,**只用来刷 mtime**,内容从没有人读。 +// 现在追加一行指令: +// +// t= +// cmd=restart reason=<理由> +// +// ★ 兼容性:老 guard 只 stat mtime、不读内容 ⇒ 追加行完全无害。 +// 新 guard 读不到 cmd 行 ⇒ 行为与改动前逐字一致。 +// +// ## ★ 指令为何必须**一次性消费** +// +// 如果 guard 读到 cmd 就执行、却不清理,那么下一次心跳(或下一次重启后的 +// 第一个心跳)仍看得到同一条 cmd ⇒ 无限重启。 +// ⇒ 消费方(guard)读完立即**原子地**把 cmd 行抹掉。 +// 用 O_TRUNC 重写整个文件(只保留 t= 行)——文件只有一个写者(worker 的 +// ticker)和一个读者(guard),且两者不会同时写(worker 退出后才轮到 guard 读)。 +// +// ## 为什么关机要 guard 来做,而不是 worker 直接调 systemctl +// +// worker 是被守护方。它自己调 `systemctl stop` 等于在父进程眼皮底下自杀, +// 且 systemd 可能把它判成"异常退出"而先计入一次失败。 +// 更根本的是:**只有 guard 知道这次退出该不该重拉**。让决策集中在一个地方。 + +import ( + "bufio" + "log" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +// 心跳指令取值。 +const ( + hbCmdRestart = "restart" + hbCmdShutdown = "shutdown" +) + +// hbCommand 是从心跳文件里读出的指令。 +type hbCommand struct { + Cmd string // "" | "restart" | "shutdown" + Reason string + // OK 表示确实读到了合法指令(Cmd 非空)。 + OK bool +} + +// parseHeartbeatCmd 解析心跳文件内容,取出指令。 +// +// 容错:任何畸形行都忽略(心跳文件是"尽力而为"的通道,不该因为 +// 一个坏行就让 guard 崩掉)。 +func parseHeartbeatCmd(content string) hbCommand { + sc := bufio.NewScanner(strings.NewReader(content)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if !strings.HasPrefix(line, "cmd=") { + continue + } + rest := strings.TrimPrefix(line, "cmd=") + var cmd, reason string + if i := strings.Index(rest, " reason="); i >= 0 { + cmd = strings.TrimSpace(rest[:i]) + reason = strings.TrimSpace(rest[i+len(" reason="):]) + } else { + cmd = strings.TrimSpace(rest) + } + switch cmd { + case hbCmdRestart, hbCmdShutdown: + return hbCommand{Cmd: cmd, Reason: reason, OK: true} + } + } + return hbCommand{} +} + +// consumeHeartbeatCmd 读出指令并**立即清除**它(一次性语义)。 +// +// 清除方式:重写文件为只含当前时间戳的心跳行,这样 mtime 仍是新的 +// (不会让 guard 误判 worker 卡死),而 cmd 行消失。 +func consumeHeartbeatCmd(path string) hbCommand { + // ★ 先看大小再决定读不读(2026-10-17 修正)。 + // + // 修复前:无条件 ReadFile(path) —— 生产上那个文件长到 14.6MB, + // 每次 worker 退出都要整读一遍(实测 27ms,纯属浪费)。 + // + // 滚动写入修好后文件只有十几到几十字节,但这里仍保留尺寸守卫: + // 老部署上那个巨型文件还在,第一次读完就会被清除,不该为此再读几次。 + const maxRead = 8 << 10 // 8KB 足够容纳 t= 行 + cmd= 行 + fi, err := os.Stat(path) + if err != nil { + return hbCommand{} + } + if fi.Size() > maxRead { + // 巨型历史文件:指令若有,一定在**末尾**(worker 最后几次心跳)。 + // 只读尾部 8KB,避免整读。 + return consumeTail(path, maxRead) + } + return consumeFrom(path) +} + +// consumeTail 只读文件尾部(老部署的巨型心跳文件)。 +func consumeTail(path string, n int64) hbCommand { + f, err := os.Open(path) + if err != nil { + return hbCommand{} + } + defer f.Close() + fi, _ := f.Stat() + off := fi.Size() - n + if off < 0 { + off = 0 + } + buf := make([]byte, n) + read, _ := f.ReadAt(buf, off) + c := parseHeartbeatCmd(string(buf[:read])) + if c.OK { + clearHeartbeatCmd(path) + } + return c +} + +// consumeFrom 读小文件(正常路径)。 +func consumeFrom(path string) hbCommand { + b, err := os.ReadFile(path) + if err != nil { + return hbCommand{} + } + c := parseHeartbeatCmd(string(b)) + if c.OK { + clearHeartbeatCmd(path) + } + return c +} + +// clearHeartbeatCmd 把心跳文件重写为只含当前时间戳的一行。 +// +// 原子(写临时文件再 rename):避免 guard 读到半截内容。 +// 同时把巨型历史文件**一次性收敛**成十几字节 —— 老部署升级后第一次 +// 消费指令就顺带清理掉了。 +func clearHeartbeatCmd(path string) { + tmp := path + ".cmdtmp" + now := []byte("t=" + strconv.FormatInt(time.Now().Unix(), 10) + "\n") + if err := os.WriteFile(tmp, now, 0644); err != nil { + return + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + } +} + +// ---- worker 侧的待发指令队列 ---- + +// pendingCmd 是"用户点了按钮,但 guard 还没消费"的指令。 +// +// 用 mutex + 单槽(不是队列):重启/关机都是**目标态**,连点两下不该 +// 排队两次。后来的请求覆盖前者即可(都指向"要重启"这件事)。 +var ( + pendingCmdMu sync.Mutex + pendingCmd hbCommand + // pendingWritten 标记这条指令是否**已经写进过心跳文件**。 + // + // ★ 为什么需要它(否则指令永远清不掉): + // guard 与 worker 是**两个进程**。guard 消费后把文件重写成纯 t= 行, + // 但 worker 内存里的 pendingCmd 还是满的 ⇒ 下一次 tick 又写回去 + // ⇒ guard 又读到 ⇒ **无限重启/关机**。 + // 真正的"已消费"信号只能从**文件**上读:写过了、而文件里的 cmd 行没了 + // ⇒ 说明被 guard 吃了 ⇒ 清本地 pending。 + pendingWritten bool +) + +// requestHeartbeatCmd 由一个外部调用(WebUI 经内核接口)投递指令。 +// +// 立即返回:真正的写入发生在下一次心跳(≤5s)。 +func requestHeartbeatCmd(cmd, reason string) { + pendingCmdMu.Lock() + pendingCmd = hbCommand{Cmd: cmd, Reason: reason, OK: true} + pendingWritten = false + pendingCmdMu.Unlock() +} + +// nextHeartbeatWrite 决定这一次心跳该写什么内容。 +// +// 返回 (content, writeFull): +// - content 非空 ⇒ 用**整文件重写**(保留 t= 行 + cmd= 行) +// - content 为空 ⇒ 走原来的 append 语义(只刷 mtime) +// +// 消费检测也在这里:若指令写过、而文件里已没有 cmd 行,说明 guard 已消费, +// 清掉本地 pending,本次回到 append。 +func nextHeartbeatWrite(hbPath string, now int64) string { + pendingCmdMu.Lock() + defer pendingCmdMu.Unlock() + + if pendingCmd.Cmd == "" { + return "" + } + if pendingWritten { + // 检查 guard 是否已消费(guard 消费 = 把文件重写成纯 t= 行) + if b, err := os.ReadFile(hbPath); err == nil && !strings.Contains(string(b), "cmd=") { + log.Printf("[homed] 控制指令 %s 已被 guard 消费,清除本地待发状态", pendingCmd.Cmd) + pendingCmd = hbCommand{} + pendingWritten = false + return "" + } + } + pendingWritten = true + return "t=" + strconv.FormatInt(now, 10) + "\ncmd=" + pendingCmd.Cmd + " reason=" + pendingCmd.Reason + "\n" +} + +// stopOwnUnit 让 systemd 停下来(不再自动拉起)。 +// +// ★ 为什么必须显式 stop,不能只是 return: +// +// 生产 unit 是 `Restart=always`。guard 干净退出后 systemd 会立刻拉起整个 +// unit —— 用户点了"关机",几秒后服务又活了,看起来像按钮没生效。 +// +// 用 `systemctl stop `: +// +// · 不依赖 unit 里配 RestartPreventExitStatus(那种做法要求 drop-in 与 +// 主 unit 一起交付,部署时容易漏) +// · 走正常停机序列(SIGTERM → TimeoutStopSec),与 guard 自己收尾不冲突 +// +// unit 名为空(非 systemd 环境 / 探测失败)时只记日志: +// 那种情况下 guard 退出后进程就真的没了,无需额外动作。 +func stopOwnUnit(unit string) { + if unit == "" { + log.Printf("[guard] 非 systemd 环境(unit 未知):guard 退出即可,systemd 不会拉起") + return + } + log.Printf("[guard] 执行 systemctl stop %s", unit) + // 同步执行:guard 此时已经没有别的事要做,且必须在**自己退出之前** + // 让 systemd 收到停止请求。异步会与 return 竞争 —— + // guard 退了、systemctl 还没跑,systemd 可能已经判定 unit 需要重启。 + cmd := exec.Command("systemctl", "stop", unit) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + log.Printf("[guard] systemctl stop %s 失败: %v(guard 仍退出;"+ + "若 unit 是 Restart=always 可能被重新拉起)", unit, err) + } +} diff --git a/cmd/homed/heartbeat_cmd_test.go b/cmd/homed/heartbeat_cmd_test.go new file mode 100644 index 00000000..eac1acf1 --- /dev/null +++ b/cmd/homed/heartbeat_cmd_test.go @@ -0,0 +1,208 @@ +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) +} diff --git a/cmd/homed/kernel_control.go b/cmd/homed/kernel_control.go new file mode 100644 index 00000000..946a621a --- /dev/null +++ b/cmd/homed/kernel_control.go @@ -0,0 +1,64 @@ +package main + +// 内核控制通道的实现:把 WebUI 的"重启/关机"请求转成心跳指令。 +// +// ## 完整链路(与心跳复用,不新开通道) +// +// WebUI 点按钮 +// → POST /api/v1/kernel/{restart,shutdown} +// → sdk.KernelControlAPI.RequestKernel*(本文件实现,只投递不等待) +// → requestHeartbeatCmd():把指令放进 worker 内存的单槽队列 +// → 下一次心跳(≤5s)把 `cmd=` 行写进 /heartbeat +// → ① 重启:worker 走正常清理 → exit 42 → guard 重拉(既有语义,零改动) +// ② 关机:guard 在 workerCleanExit 分支读到 cmd=shutdown +// → systemctl stop → 不再拉起 +// +// ## ★ 为什么不用"worker 自己调 systemctl stop" +// +// worker 是被守护方。它自己停服务等于在父进程眼皮底下自杀,而且 +// systemd 可能先把这次退出记成一次失败。更根本的是: +// **只有 guard 知道这次退出该不该重拉** —— 决策该集中在一个地方。 +// +// ## 重启为什么几乎零改动 +// +// `exitRestartRequested=42` 与 guard 的 `workerRestartRequested` 分支早已存在 +// 且生产验证过(热更新就走这条路)。我们只是给"用户点按钮"也接到同一个出口。 + +import ( + "fmt" +) + +// kernelControlImpl 实现 sdk.KernelControlAPI。 +type kernelControlImpl struct { + // restartCh 是内核主循环的重启通道(既有机制,supervisor 也用)。 + restartCh chan<- struct{} +} + +// RequestKernelRestart 请求热重启。 +// +// 走**既有**的 restartCh(与 supervisor.Restart 同一条路): +// worker 清理 → exit 42 → guard 用当前二进制重拉,不计失败轮次。 +func (k *kernelControlImpl) RequestKernelRestart(reason string) error { + if k.restartCh == nil { + return fmt.Errorf("内核重启通道未装配") + } + select { + case k.restartCh <- struct{}{}: + return nil + default: + // 已有待处理的重启请求 ⇒ 目标一致,视为成功(幂等) + return nil + } +} + +// RequestKernelShutdown 请求关机。 +// +// ★ 与重启的区别:关机**不能**复用 restartCh(那会被 guard 重拉)。 +// 必须让 guard 在 worker 退出后知道"这次别再起了" ⇒ 写心跳指令。 +// +// 这里只投递;真正的写入在 worker 的下一次心跳(≤5s), +// 因此本方法立即返回 —— 调用方(HTTP handler)需要的是"请求已受理"。 +func (k *kernelControlImpl) RequestKernelShutdown(reason string) error { + requestHeartbeatCmd(hbCmdShutdown, reason) + return nil +} diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 9d5545c6..ba4bd101 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -282,6 +282,14 @@ func main() { restartCh := startSupervisorRuntime(sup, trk, agent) + // 内核控制通道(WebUI 的重启/关机按钮走这里)。 + // + // ★ 必须在插件加载**之前**注入:WebUI 在 Start 里取 + // s.KernelControl(),注入晚了它拿到 nil ⇒ 按钮永远报"不可用"。 + // 装配顺序在模块化内核里由依赖图决定,而本行位于 agent 模块之后、 + // plugin-host 拉起插件之前 —— 正是需要的位置。 + pluginReg.SetKernelControl(&kernelControlImpl{restartCh: restartCh}) + log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d", cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters())) log.Printf("[homed] kernel ready, waiting for plugin IO...") diff --git a/internal/plugin/kernel_control_gate_test.go b/internal/plugin/kernel_control_gate_test.go new file mode 100644 index 00000000..ce60aeb9 --- /dev/null +++ b/internal/plugin/kernel_control_gate_test.go @@ -0,0 +1,89 @@ +package plugin + +// 内核控制通道的**授权边界**判据(2026-10-17)。 +// +// ## 为什么这条判据必须存在 +// +// 重启/关机通道能让**整个服务停下来**,而且关机后不会自己回来 +// (guard 显式 systemctl stop 压制了 Restart=always)。 +// +// 我做这个功能时第一版是"有就发":`KernelControl: r.kernelCtl` 无条件 +// 传给**所有**内置插件。那意味着任何一个内置插件(包括将来新加的) +// 都能一键关掉整个服务 —— 攻击面 = 全部内置插件。 +// +// ⇒ 必须按插件名白名单授予,且这条边界要有判据钉住。 + +import ( + "context" + "testing" + + sdk "github.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// fakeKernelCtl 记录收到的请求(判据只关心"能不能拿到/调用")。 +type fakeKernelCtl struct { + restartCalls int + shutdownCalls int +} + +func (f *fakeKernelCtl) RequestKernelRestart(reason string) error { + f.restartCalls++ + return nil +} +func (f *fakeKernelCtl) RequestKernelShutdown(reason string) error { + f.shutdownCalls++ + return nil +} + +// ★★★ 判据:只有白名单里的插件能拿到内核控制通道。 +func TestKernelControlOnlyForAllowlistedPlugins(t *testing.T) { + r := NewRegistry() + fake := &fakeKernelCtl{} + r.SetKernelControl(fake) + + if got := r.kernelControlFor("webui"); got == nil { + t.Fatal("★ webui 应拿到内核控制通道(它是有鉴权的管理界面)") + } + // 典型的内置插件都不该拿到 + for _, name := range []string{"cmd", "files", "memo", "healthcheck", "pluginmgr", "timer"} { + if got := r.kernelControlFor(name); got != nil { + t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+ + "任何插件都能关机整个服务,攻击面失控", name) + } + } + // 未知/外部插件名 + if got := r.kernelControlFor("some_third_party"); got != nil { + t.Fatal("★ 未登记插件拿到了内核控制通道") + } +} + +// ★★★ 判据:内核未装配控制通道时,白名单插件也只拿到 nil(不 panic)。 +func TestKernelControlNilWhenNotWired(t *testing.T) { + r := NewRegistry() + // 故意不 SetKernelControl(模拟非 systemd 环境/未启用) + if got := r.kernelControlFor("webui"); got != nil { + t.Fatal("★ 通道未装配时不该返回非 nil") + } +} + +// ★★★ 判据:SDK 侧 KernelControl() 未注入时返回 nil,且调用方判 nil 不 panic。 +func TestSDKKernelControlIsNilSafe(t *testing.T) { + s := sdk.New("probe", sdk.SDKConfig{}) + if kc := s.KernelControl(); kc != nil { + t.Fatal("未注入时 KernelControl() 应为 nil") + } + // 注入后能拿到 + fake := &fakeKernelCtl{} + s2 := sdk.New("probe2", sdk.SDKConfig{KernelControl: fake}) + if s2.KernelControl() == nil { + t.Fatal("注入后应能拿到") + } + if err := s2.KernelControl().RequestKernelRestart("t"); err != nil { + t.Fatalf("调用失败: %v", err) + } + if fake.restartCalls != 1 { + t.Fatalf("fake 未收到请求(%d)", fake.restartCalls) + } +} + +var _ = context.Background diff --git a/internal/plugin/proc/capability.go b/internal/plugin/proc/capability.go index c463e8af..cfeb02bd 100644 --- a/internal/plugin/proc/capability.go +++ b/internal/plugin/proc/capability.go @@ -63,6 +63,20 @@ const ( // 这是**最敏感**的一组:能重载其他插件意味着能间接影响它们的状态。 CapPluginMgr Capability = "plugin_mgr" + // CapKernelControl 控制**内核本身**的生死(重启/关机)。 + // + // 这是整个能力表里**最危险**的一档:它不改变某个插件的状态、 + // 也不改配置,而是让**整个服务**停下来。 + // + // 与 CapPluginMgr 的区别(别混): + // CapPluginMgr 能重载**别的插件** —— 影响一个插件 + // CapKernelControl 能重启/关机**内核** —— 影响全部插件 + 全部用户 + // + // 关机之后服务不会自己回来(systemd Restart=always 由 guard 显式 + // systemctl stop 压制),必须人工介入。因此默认**不授予**任何插件, + // 必须由 manifest 显式声明。 + CapKernelControl Capability = "kernel_control" + // CapCrossPluginSettings 读写**其他插件**的配置与内核核心配置。 // // 与 CapCore 里的「读写自己的配置」区分开:跨插件配置读写能改别人的行为, @@ -160,11 +174,14 @@ var methodCapability = map[string]Capability{ MethodEventsUnsubscribe: CapEvents, // ---- 插件管理 ---- - MethodPluginReloadOne: CapPluginMgr, - MethodPluginListLoaded: CapPluginMgr, - MethodPluginIsDisabled: CapPluginMgr, - MethodPluginMgrStop: CapPluginMgr, - MethodPluginMgrStart: CapPluginMgr, + MethodPluginReloadOne: CapPluginMgr, + MethodPluginListLoaded: CapPluginMgr, + MethodPluginIsDisabled: CapPluginMgr, + MethodPluginMgrStop: CapPluginMgr, + MethodPluginMgrStart: CapPluginMgr, + // 内核生命周期控制:单独一档,见 CapKernelControl 的注释。 + MethodKernelRestart: CapKernelControl, + MethodKernelShutdown: CapKernelControl, MethodPluginMgrIsRuntimeStopped: CapPluginMgr, // ---- 跨插件 / 核心配置 ---- diff --git a/internal/plugin/proc/protocol.go b/internal/plugin/proc/protocol.go index e953285c..308e0d51 100644 --- a/internal/plugin/proc/protocol.go +++ b/internal/plugin/proc/protocol.go @@ -213,6 +213,14 @@ const ( MethodPluginMgrStart = "plugin.mgrStart" // 52 MethodPluginMgrIsRuntimeStopped = "plugin.mgrIsRuntimeStopped" // 53 + // ---- 内核控制(重启/关机,供 WebUI 等界面插件用)---- + // + // ★ 同样是**必须桥接**的方法:子进程插件走本桥,未登记的方法会被 + // corehandler 直接拒("未知 method")。WebUI 是子进程插件, + // 不走这里就点不动按钮。 + MethodKernelRestart = "kernel.restart" // 54 + MethodKernelShutdown = "kernel.shutdown" // 55 + // 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念) MethodStageLock = "stage.lock" MethodStageUnlock = "stage.unlock" diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 7a6304c9..3467c6d8 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -75,6 +75,9 @@ type PluginStageCleaner interface { } type Registry struct { + // kernelCtl 是内核重启/关机通道(装配时注入,可 nil)。 + // 只对声明了该能力的内置插件可见(目前是 WebUI)。 + kernelCtl sdk.KernelControlAPI mu sync.RWMutex plugins map[string]sdk.Plugin instances []sdk.Plugin @@ -228,15 +231,48 @@ func (r *Registry) SetPluginDir(dir string) { r. func (r *Registry) SetDataDir(dir string) { r.dataDir = dir } func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm } func (r *Registry) SetBaseAPIKey(key string) { r.baseKey = key } -func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn } -func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn } -func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn } -func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc } -func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp } -func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup } -func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk } -func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg } -func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh } + +// kernelControlAllowed 决定某个内置插件能否拿到内核重启/关机通道。 +// +// ★ 为什么必须白名单,而不是"有就给": +// +// 这条通道能让**整个服务停下来**(关机后不会自己回来)。 +// 内置插件是编译进内核的,但它们的代码同样可能被改坏或有 bug; +// 把这种能力发给每一个内置插件,等于整个攻击面 = 全部内置插件。 +// +// 目前只给 WebUI:它是有登录鉴权的管理界面,用户已经决定信任它。 +// 将来要开放给别的插件,必须逐个评估(它有没有鉴权、会不会误触发)。 +var kernelControlAllowed = map[string]bool{ + "webui": true, +} + +// SetKernelControl 注入内核重启/关机通道(由 cmd/homed 装配时调用)。 +// +// ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed +// +// (那是反向依赖,且 cmd 包不可被 import)。用接口 + setter 打破方向。 +func (r *Registry) SetKernelControl(k sdk.KernelControlAPI) { r.kernelCtl = k } + +// kernelControlFor 按插件名决定是否授予内核控制通道。 +// +// 未在白名单里的插件拿到 nil —— SDK 侧 KernelControl() 返回 nil, +// 调用方(插件自己)必须判 nil 并如实告知用户"当前环境不支持"。 +func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI { + if r.kernelCtl == nil || !kernelControlAllowed[name] { + return nil + } + return r.kernelCtl +} + +func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn } +func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn } +func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn } +func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc } +func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp } +func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup } +func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk } +func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg } +func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh } // SetDeviceAuthQuery 注入"设备是否已授权"的查询(D4)。 // @@ -410,6 +446,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { RegOutputUnreg: regOutputUnreg, RegInput: regInput, PluginMgr: r, + KernelControl: r.kernelControlFor(name), Status: r.status, Supervisor: r.sup, diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 36a3a4eb..aaf637d8 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -140,6 +140,35 @@ > EN + + +