Files
HomeAgent/cmd/homed/kernel_control.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

65 lines
2.5 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
// 内核控制通道的实现:把 WebUI 的"重启/关机"请求转成心跳指令。
//
// ## 完整链路(与心跳复用,不新开通道)
//
// WebUI 点按钮
// → POST /api/v1/kernel/{restart,shutdown}
// → sdk.KernelControlAPI.RequestKernel*(本文件实现,只投递不等待)
// → requestHeartbeatCmd():把指令放进 worker 内存的单槽队列
// → 下一次心跳(≤5s)把 `cmd=` 行写进 <data>/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
}