mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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,无法区分"正在重启"和"真出故障了"。
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -120,3 +120,6 @@ dist/
|
|||||||
|
|
||||||
# ── 本地 AI 工具的仓库级配置(每人偏好不同,不进仓库)──
|
# ── 本地 AI 工具的仓库级配置(每人偏好不同,不进仓库)──
|
||||||
.pi-lens.json
|
.pi-lens.json
|
||||||
|
|
||||||
|
# agent 会话附件(本地产物,不进版本库)
|
||||||
|
.pi-web/
|
||||||
|
|||||||
@ -227,7 +227,30 @@ func startHeartbeat(dataDir string, ctx context.Context) func() {
|
|||||||
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
// 心跳:每 5s 触碰 <data>/heartbeat,guard 据此判定工作进程是否存活/卡死
|
||||||
hbPath := filepath.Join(dataDir, "heartbeat")
|
hbPath := filepath.Join(dataDir, "heartbeat")
|
||||||
writeHB := func() {
|
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())
|
fmt.Fprintf(f, "t=%d\n", time.Now().Unix())
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -256,7 +256,25 @@ func runGuard(dataDir string) {
|
|||||||
|
|
||||||
switch outcome {
|
switch outcome {
|
||||||
case workerCleanExit:
|
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")
|
log.Printf("[guard] worker exited cleanly, guard exiting")
|
||||||
|
// ★ 注意:这里 `return` 后 guard 退出,systemd(Restart=always)
|
||||||
|
// 会重新拉起整个 unit —— 这正是"重启"的既有语义,保持不动。
|
||||||
return
|
return
|
||||||
case workerRestartRequested:
|
case workerRestartRequested:
|
||||||
log.Printf("[guard] worker requested restart, respawning")
|
log.Printf("[guard] worker requested restart, respawning")
|
||||||
|
|||||||
253
cmd/homed/heartbeat_cmd.go
Normal file
253
cmd/homed/heartbeat_cmd.go
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 心跳里的**控制指令**:worker → guard 的唯一意图通道。
|
||||||
|
//
|
||||||
|
// ## 为什么走心跳,而不是另开一条通道
|
||||||
|
//
|
||||||
|
// guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件)。
|
||||||
|
// 用户点"重启/关机"本质就是"我要你在下一次心跳时知道这件事"——
|
||||||
|
// 开第二条通道(unix socket / 信号 / 临时文件)只会多一套要防重、防丢、
|
||||||
|
// 防竞态的机制,而这条已经存在且有 mtime 存活语义。
|
||||||
|
//
|
||||||
|
// ## 格式演进(必须向后兼容)
|
||||||
|
//
|
||||||
|
// 原格式每行 `t=<unix>\n`,**只用来刷 mtime**,内容从没有人读。
|
||||||
|
// 现在追加一行指令:
|
||||||
|
//
|
||||||
|
// t=<unix>
|
||||||
|
// 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>`:
|
||||||
|
//
|
||||||
|
// · 不依赖 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
208
cmd/homed/heartbeat_cmd_test.go
Normal file
208
cmd/homed/heartbeat_cmd_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
64
cmd/homed/kernel_control.go
Normal file
64
cmd/homed/kernel_control.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -282,6 +282,14 @@ func main() {
|
|||||||
|
|
||||||
restartCh := startSupervisorRuntime(sup, trk, agent)
|
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",
|
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()))
|
cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters()))
|
||||||
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
log.Printf("[homed] kernel ready, waiting for plugin IO...")
|
||||||
|
|||||||
89
internal/plugin/kernel_control_gate_test.go
Normal file
89
internal/plugin/kernel_control_gate_test.go
Normal file
@ -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
|
||||||
@ -63,6 +63,20 @@ const (
|
|||||||
// 这是**最敏感**的一组:能重载其他插件意味着能间接影响它们的状态。
|
// 这是**最敏感**的一组:能重载其他插件意味着能间接影响它们的状态。
|
||||||
CapPluginMgr Capability = "plugin_mgr"
|
CapPluginMgr Capability = "plugin_mgr"
|
||||||
|
|
||||||
|
// CapKernelControl 控制**内核本身**的生死(重启/关机)。
|
||||||
|
//
|
||||||
|
// 这是整个能力表里**最危险**的一档:它不改变某个插件的状态、
|
||||||
|
// 也不改配置,而是让**整个服务**停下来。
|
||||||
|
//
|
||||||
|
// 与 CapPluginMgr 的区别(别混):
|
||||||
|
// CapPluginMgr 能重载**别的插件** —— 影响一个插件
|
||||||
|
// CapKernelControl 能重启/关机**内核** —— 影响全部插件 + 全部用户
|
||||||
|
//
|
||||||
|
// 关机之后服务不会自己回来(systemd Restart=always 由 guard 显式
|
||||||
|
// systemctl stop 压制),必须人工介入。因此默认**不授予**任何插件,
|
||||||
|
// 必须由 manifest 显式声明。
|
||||||
|
CapKernelControl Capability = "kernel_control"
|
||||||
|
|
||||||
// CapCrossPluginSettings 读写**其他插件**的配置与内核核心配置。
|
// CapCrossPluginSettings 读写**其他插件**的配置与内核核心配置。
|
||||||
//
|
//
|
||||||
// 与 CapCore 里的「读写自己的配置」区分开:跨插件配置读写能改别人的行为,
|
// 与 CapCore 里的「读写自己的配置」区分开:跨插件配置读写能改别人的行为,
|
||||||
@ -160,11 +174,14 @@ var methodCapability = map[string]Capability{
|
|||||||
MethodEventsUnsubscribe: CapEvents,
|
MethodEventsUnsubscribe: CapEvents,
|
||||||
|
|
||||||
// ---- 插件管理 ----
|
// ---- 插件管理 ----
|
||||||
MethodPluginReloadOne: CapPluginMgr,
|
MethodPluginReloadOne: CapPluginMgr,
|
||||||
MethodPluginListLoaded: CapPluginMgr,
|
MethodPluginListLoaded: CapPluginMgr,
|
||||||
MethodPluginIsDisabled: CapPluginMgr,
|
MethodPluginIsDisabled: CapPluginMgr,
|
||||||
MethodPluginMgrStop: CapPluginMgr,
|
MethodPluginMgrStop: CapPluginMgr,
|
||||||
MethodPluginMgrStart: CapPluginMgr,
|
MethodPluginMgrStart: CapPluginMgr,
|
||||||
|
// 内核生命周期控制:单独一档,见 CapKernelControl 的注释。
|
||||||
|
MethodKernelRestart: CapKernelControl,
|
||||||
|
MethodKernelShutdown: CapKernelControl,
|
||||||
MethodPluginMgrIsRuntimeStopped: CapPluginMgr,
|
MethodPluginMgrIsRuntimeStopped: CapPluginMgr,
|
||||||
|
|
||||||
// ---- 跨插件 / 核心配置 ----
|
// ---- 跨插件 / 核心配置 ----
|
||||||
|
|||||||
@ -213,6 +213,14 @@ const (
|
|||||||
MethodPluginMgrStart = "plugin.mgrStart" // 52
|
MethodPluginMgrStart = "plugin.mgrStart" // 52
|
||||||
MethodPluginMgrIsRuntimeStopped = "plugin.mgrIsRuntimeStopped" // 53
|
MethodPluginMgrIsRuntimeStopped = "plugin.mgrIsRuntimeStopped" // 53
|
||||||
|
|
||||||
|
// ---- 内核控制(重启/关机,供 WebUI 等界面插件用)----
|
||||||
|
//
|
||||||
|
// ★ 同样是**必须桥接**的方法:子进程插件走本桥,未登记的方法会被
|
||||||
|
// corehandler 直接拒("未知 method")。WebUI 是子进程插件,
|
||||||
|
// 不走这里就点不动按钮。
|
||||||
|
MethodKernelRestart = "kernel.restart" // 54
|
||||||
|
MethodKernelShutdown = "kernel.shutdown" // 55
|
||||||
|
|
||||||
// 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念)
|
// 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念)
|
||||||
MethodStageLock = "stage.lock"
|
MethodStageLock = "stage.lock"
|
||||||
MethodStageUnlock = "stage.unlock"
|
MethodStageUnlock = "stage.unlock"
|
||||||
|
|||||||
@ -75,6 +75,9 @@ type PluginStageCleaner interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
|
// kernelCtl 是内核重启/关机通道(装配时注入,可 nil)。
|
||||||
|
// 只对声明了该能力的内置插件可见(目前是 WebUI)。
|
||||||
|
kernelCtl sdk.KernelControlAPI
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
plugins map[string]sdk.Plugin
|
plugins map[string]sdk.Plugin
|
||||||
instances []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) SetDataDir(dir string) { r.dataDir = dir }
|
||||||
func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }
|
func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }
|
||||||
func (r *Registry) SetBaseAPIKey(key string) { r.baseKey = key }
|
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 }
|
// kernelControlAllowed 决定某个内置插件能否拿到内核重启/关机通道。
|
||||||
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 }
|
// 内置插件是编译进内核的,但它们的代码同样可能被改坏或有 bug;
|
||||||
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
|
// 把这种能力发给每一个内置插件,等于整个攻击面 = 全部内置插件。
|
||||||
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
|
//
|
||||||
|
// 目前只给 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)。
|
// SetDeviceAuthQuery 注入"设备是否已授权"的查询(D4)。
|
||||||
//
|
//
|
||||||
@ -410,6 +446,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
|||||||
RegOutputUnreg: regOutputUnreg,
|
RegOutputUnreg: regOutputUnreg,
|
||||||
RegInput: regInput,
|
RegInput: regInput,
|
||||||
PluginMgr: r,
|
PluginMgr: r,
|
||||||
|
KernelControl: r.kernelControlFor(name),
|
||||||
|
|
||||||
Status: r.status,
|
Status: r.status,
|
||||||
Supervisor: r.sup,
|
Supervisor: r.sup,
|
||||||
|
|||||||
@ -140,6 +140,35 @@
|
|||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 内核控制:重启 / 关机。
|
||||||
|
放在导航底部而不是页面里,是因为它们作用于**整个服务**,
|
||||||
|
不属于任何一个标签页。 -->
|
||||||
|
<button
|
||||||
|
class="theme-btn"
|
||||||
|
id="kernel-restart-btn"
|
||||||
|
onclick="kernelRestart()"
|
||||||
|
title="重启内核(服务约断线数秒后自动重连)"
|
||||||
|
aria-label="重启内核"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
|
||||||
|
<path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="theme-btn"
|
||||||
|
id="kernel-shutdown-btn"
|
||||||
|
onclick="kernelShutdown()"
|
||||||
|
title="关闭内核(需人工 systemctl start 才能再启动)"
|
||||||
|
aria-label="关闭内核"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M12 3v9" />
|
||||||
|
<path d="M6.3 6.3a8 8 0 1 0 11.4 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm"
|
class="btn btn-ghost btn-sm"
|
||||||
onclick="logout()"
|
onclick="logout()"
|
||||||
|
|||||||
@ -118,10 +118,12 @@ type Handler struct {
|
|||||||
knowledge sdk.KnowledgeAPI
|
knowledge sdk.KnowledgeAPI
|
||||||
tracker sdk.TrackerAPI
|
tracker sdk.TrackerAPI
|
||||||
settings sdk.SettingsAPI
|
settings sdk.SettingsAPI
|
||||||
pluginMgr sdk.PluginManager
|
// kernelCtl 是内核重启/关机通道。nil = 该部署没接(端点会明确报不可用)。
|
||||||
status sdk.StatusAPI
|
kernelCtl sdk.KernelControlAPI
|
||||||
term sdk.TerminalAPI
|
pluginMgr sdk.PluginManager
|
||||||
llm sdk.LLMAPI
|
status sdk.StatusAPI
|
||||||
|
term sdk.TerminalAPI
|
||||||
|
llm sdk.LLMAPI
|
||||||
|
|
||||||
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
|
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
|
||||||
hostPort string
|
hostPort string
|
||||||
@ -183,6 +185,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
|||||||
st sdk.StatusAPI
|
st sdk.StatusAPI
|
||||||
term sdk.TerminalAPI
|
term sdk.TerminalAPI
|
||||||
llm sdk.LLMAPI
|
llm sdk.LLMAPI
|
||||||
|
// kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。
|
||||||
|
kc sdk.KernelControlAPI
|
||||||
)
|
)
|
||||||
if s != nil {
|
if s != nil {
|
||||||
sup, mem, idx = s.Supervisor(), s.Memory(), s.Indexer()
|
sup, mem, idx = s.Supervisor(), s.Memory(), s.Indexer()
|
||||||
@ -191,6 +195,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
|||||||
se, pm = s.Settings(), s.PluginMgr()
|
se, pm = s.Settings(), s.PluginMgr()
|
||||||
st, llm = s.Status(), s.LLM()
|
st, llm = s.Status(), s.LLM()
|
||||||
term = s.Terminal()
|
term = s.Terminal()
|
||||||
|
kc = s.KernelControl()
|
||||||
}
|
}
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
sdk: s,
|
sdk: s,
|
||||||
@ -204,6 +209,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
|||||||
knowledge: ks,
|
knowledge: ks,
|
||||||
tracker: tr,
|
tracker: tr,
|
||||||
settings: se,
|
settings: se,
|
||||||
|
kernelCtl: kc,
|
||||||
pluginMgr: pm,
|
pluginMgr: pm,
|
||||||
status: st,
|
status: st,
|
||||||
term: term,
|
term: term,
|
||||||
@ -474,6 +480,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/login", h.handleLogin)
|
mux.HandleFunc("/api/v1/login", h.handleLogin)
|
||||||
mux.HandleFunc("/api/v1/logout", h.handleLogout)
|
mux.HandleFunc("/api/v1/logout", h.handleLogout)
|
||||||
mux.HandleFunc("/api/v1/status", h.requireAPI(h.handleStatus))
|
mux.HandleFunc("/api/v1/status", h.requireAPI(h.handleStatus))
|
||||||
|
// 内核控制:重启 / 关机。
|
||||||
|
//
|
||||||
|
// 都要鉴权:能停服务的人必须是已认证的管理者。这两个端点比"读状态"
|
||||||
|
// 危险一个量级 —— 关机之后只能 ssh 上去才能恢复。
|
||||||
|
mux.HandleFunc("/api/v1/kernel/restart", h.requireAPI(h.handleKernelRestart))
|
||||||
|
mux.HandleFunc("/api/v1/kernel/shutdown", h.requireAPI(h.handleKernelShutdown))
|
||||||
mux.HandleFunc("/api/v1/agents", h.requireAPI(h.handleAgents))
|
mux.HandleFunc("/api/v1/agents", h.requireAPI(h.handleAgents))
|
||||||
mux.HandleFunc("/api/v1/agents/", h.requireAPI(h.handleAgentByID))
|
mux.HandleFunc("/api/v1/agents/", h.requireAPI(h.handleAgentByID))
|
||||||
mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory))
|
mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory))
|
||||||
|
|||||||
102
internal/plugins/webui/kernel_control.go
Normal file
102
internal/plugins/webui/kernel_control.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
// 内核重启 / 关机的 HTTP 端点。
|
||||||
|
//
|
||||||
|
// ## 为什么这两个端点必须在 WebUI 里
|
||||||
|
//
|
||||||
|
// 有些状态变更只能靠重启内核生效(换 LLM 源、换主密钥、装配期配置)。
|
||||||
|
// 没有这两个按钮时,用户只能自己 ssh 上去敲 systemctl —— 而 WebUI
|
||||||
|
// 是大多数人唯一的界面。
|
||||||
|
//
|
||||||
|
// ## 断线是**预期行为**,不是故障
|
||||||
|
//
|
||||||
|
// 两个动作都会让内核进程消失:
|
||||||
|
// · 重启:guard 用当前二进制重拉,断线几秒
|
||||||
|
// · 关机:systemd 不再拉起,断线到人工启动
|
||||||
|
//
|
||||||
|
// ⇒ 必须**先把响应写回浏览器**,再让内核退出。否则用户看到的是
|
||||||
|
// "点了一下,连接被重置",无法区分"正在重启"和"真出故障了"。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleKernelRestart 处理 POST /api/v1/kernel/restart
|
||||||
|
func (h *Handler) handleKernelRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.kernelControl(w, r, "restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKernelShutdown 处理 POST /api/v1/kernel/shutdown
|
||||||
|
func (h *Handler) handleKernelShutdown(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.kernelControl(w, r, "shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) kernelControl(w http.ResponseWriter, r *http.Request, action string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kc := h.kernelCtl
|
||||||
|
if kc == nil {
|
||||||
|
// 明确说"不可用"而不是静默失败:用户点按钮没反应会以为界面坏了
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable,
|
||||||
|
"当前部署没有接入内核控制通道(该构建未启用,或插件未声明 kernel-control 能力)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
// body 可为空(curl 直接 POST 不带内容)
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
reason := strings.TrimSpace(body.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = "WebUI 操作"
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if action == "restart" {
|
||||||
|
err = kc.RequestKernelRestart(reason)
|
||||||
|
} else {
|
||||||
|
err = kc.RequestKernelShutdown(reason)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 先把响应写回去(Flush),再让内核消失。
|
||||||
|
//
|
||||||
|
// 不 Flush 的话响应可能还在 net/http 的缓冲里,进程就退出了 ⇒
|
||||||
|
// 浏览器收到的是 connection reset,用户以为出错了。
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"action": action,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
if action == "restart" {
|
||||||
|
msg["message"] = "内核正在重启(约数秒)。页面稍后会自动重连。"
|
||||||
|
msg["reconnect_hint_ms"] = 8000
|
||||||
|
} else {
|
||||||
|
msg["message"] = "内核正在关闭。需要人工执行 systemctl start 才能再启动。"
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(msg)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给浏览器一点时间收包,再让内核真退出
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSONError 是 handler 里统一的错误输出(保持与其它端点一致)。
|
||||||
|
func writeJSONError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
9
internal/sdk/kernel.go
Normal file
9
internal/sdk/kernel.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
import pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||||
|
|
||||||
|
// KernelControlAPI 是内核重启/关机通道(转发公开 SDK 的同名接口)。
|
||||||
|
//
|
||||||
|
// 内置插件(WebUI)与外部插件面对的是同一份契约 —— 只做类型别名,
|
||||||
|
// 不额外加方法:加方法会让"内置插件能调、外部插件调不到"成为既成事实。
|
||||||
|
type KernelControlAPI = pubsdk.KernelControlAPI
|
||||||
@ -317,6 +317,8 @@ type SDKConfig struct {
|
|||||||
RegOutputUnreg OutputChannelUnregistrar
|
RegOutputUnreg OutputChannelUnregistrar
|
||||||
RegInput InputChannelRegistrar
|
RegInput InputChannelRegistrar
|
||||||
PluginMgr PluginManager
|
PluginMgr PluginManager
|
||||||
|
// KernelControl 是内核重启/关机通道(WebUI 用)。nil = 未接该能力。
|
||||||
|
KernelControl KernelControlAPI
|
||||||
|
|
||||||
Status StatusAPI
|
Status StatusAPI
|
||||||
Supervisor SupervisorAPI
|
Supervisor SupervisorAPI
|
||||||
@ -353,6 +355,9 @@ func New(name string, cfg SDKConfig) *PluginSDK {
|
|||||||
base.SetDocMemoryAPI(cfg.DocMemory)
|
base.SetDocMemoryAPI(cfg.DocMemory)
|
||||||
base.SetKnowledgeAPI(cfg.Knowledge)
|
base.SetKnowledgeAPI(cfg.Knowledge)
|
||||||
base.SetLLMAPI(cfg.LLM)
|
base.SetLLMAPI(cfg.LLM)
|
||||||
|
if cfg.KernelControl != nil {
|
||||||
|
base.SetKernelControlAPI(cfg.KernelControl)
|
||||||
|
}
|
||||||
return &PluginSDK{
|
return &PluginSDK{
|
||||||
PluginSDK: base,
|
PluginSDK: base,
|
||||||
settings: cfg.Settings,
|
settings: cfg.Settings,
|
||||||
|
|||||||
36
third_party/homeagent-sdk/sdk/kernel.go
vendored
Normal file
36
third_party/homeagent-sdk/sdk/kernel.go
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
// KernelControlAPI:插件请求**内核本身**重启或关机。
|
||||||
|
//
|
||||||
|
// ## 为什么需要它
|
||||||
|
//
|
||||||
|
// 有些状态变更只能靠重启内核生效(典型:改了 LLM 源、换了主密钥、
|
||||||
|
// 升级了要重新装配的配置)。没有这条通道时,用户只能自己去 ssh 上敲
|
||||||
|
// systemctl —— 而 WebUI 是大多数人唯一会用的界面。
|
||||||
|
//
|
||||||
|
// ## 语义边界(重要,别指望它做更多)
|
||||||
|
//
|
||||||
|
// 这两个方法都是**异步**的:它们只把请求投递给内核,立刻返回。
|
||||||
|
// 调用方(WebUI)应当先给用户一个明确回应("正在重启,页面稍后自动重连"),
|
||||||
|
// 而不是等待返回 —— 内核真的会在这几秒内消失。
|
||||||
|
//
|
||||||
|
// 进程会怎么走:
|
||||||
|
//
|
||||||
|
// RequestKernelRestart ⇒ 内核清理(停插件、flush、CloseAll)→ exit 42
|
||||||
|
// → guard 用**当前二进制**重拉,不经过 systemd、
|
||||||
|
// 不计失败轮次(这就是"热更新"那条路)
|
||||||
|
// RequestKernelShutdown ⇒ 内核清理 → 通知 guard 主动停机 → systemd 不再拉起
|
||||||
|
//
|
||||||
|
// ★ 关机为什么不能用"guard 退出"实现:systemd 是 Restart=always,
|
||||||
|
// guard 退出会被它无条件重新拉起。必须显式 `systemctl stop`)
|
||||||
|
//
|
||||||
|
// ## 失败语义
|
||||||
|
//
|
||||||
|
// 返回错误只表示**请求没被接受**(例如内核未装配控制通道、或该功能被禁用),
|
||||||
|
// 不表示"没重启成功"——成功投递后本进程就会退出,返回值本身没有后续。
|
||||||
|
type KernelControlAPI interface {
|
||||||
|
// RequestKernelRestart 请求重启内核(热重启,guard 重拉)。
|
||||||
|
RequestKernelRestart(reason string) error
|
||||||
|
// RequestKernelShutdown 请求关闭内核(systemd 不再拉起)。
|
||||||
|
RequestKernelShutdown(reason string) error
|
||||||
|
}
|
||||||
20
third_party/homeagent-sdk/sdk/plugin.go
vendored
20
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -480,6 +480,7 @@ type PluginSDK struct {
|
|||||||
rawFiles RawFilesAPI
|
rawFiles RawFilesAPI
|
||||||
events EventSubscriber
|
events EventSubscriber
|
||||||
plgMgr PluginMgrAPI
|
plgMgr PluginMgrAPI
|
||||||
|
kernelCtl KernelControlAPI
|
||||||
|
|
||||||
// proxyReg 是反代声明的注册回调(内置插件经 RegisterProxy 声明服务)。
|
// proxyReg 是反代声明的注册回调(内置插件经 RegisterProxy 声明服务)。
|
||||||
// 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start
|
// 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start
|
||||||
@ -823,6 +824,25 @@ func (s *PluginSDK) PluginMgr() PluginMgrAPI {
|
|||||||
return s.plgMgr
|
return s.plgMgr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetKernelControlAPI sets the kernel control API (restart/shutdown).
|
||||||
|
// Called by the bridge at startup; nil means "not available".
|
||||||
|
func (s *PluginSDK) SetKernelControlAPI(k KernelControlAPI) {
|
||||||
|
s.apiMu.Lock()
|
||||||
|
s.kernelCtl = k
|
||||||
|
s.apiMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KernelControl returns the kernel control API (restart / shutdown).
|
||||||
|
//
|
||||||
|
// 可能为 nil(宿主没接这条通道,或插件没有 kernel-control 能力组)。
|
||||||
|
// 调用方必须判 nil —— 拿到 nil 时应当如实告知用户"当前环境不支持",
|
||||||
|
// 而不是假装点了没反应的按钮还能用。
|
||||||
|
func (s *PluginSDK) KernelControl() KernelControlAPI {
|
||||||
|
s.apiMu.RLock()
|
||||||
|
defer s.apiMu.RUnlock()
|
||||||
|
return s.kernelCtl
|
||||||
|
}
|
||||||
|
|
||||||
// ---- IO Convenience Methods ----
|
// ---- IO Convenience Methods ----
|
||||||
|
|
||||||
// injector 取当前 injector 的快照。
|
// injector 取当前 injector 的快照。
|
||||||
|
|||||||
Reference in New Issue
Block a user