Files
HomeAgent/internal/plugin/runtime_state_test.go
JianFeeeee 4e70b8d5d8 refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 1,不含 SDK 仓与目录改名。

- go.mod 第 1 行 module 路径
- 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换
  (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5)
- 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、
  homed/waiter 可执行名、.hmap 后缀
2026-10-21 12:17:19 +08:00

309 lines
11 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 plugin
// 插件**运行态 vs 持久态**分离的判据(2026-10-16)。
//
// 设计见 docs/zh/kernel-modularization-design.md §2.6.7。
//
// ## 这组判据守的是什么
//
// 用户指出"只有 reload" —— 实测确认现有 API 全都是
// "改运行态 **顺带** 改持久态":
//
// DisablePlugin → cfgReg.AddDisabledPlugin (持久禁用)
// Enable → cfgReg.RemoveDisabledPlugin (解除持久禁用)
//
// 于是 agent **无法**"只是现在关掉它"而不影响开机自启。
// 这组判据要求:运行态操作**绝不**触碰持久状态。
//
// ## 判据必须测真实加载路径
//
// 本仓教训(重复 10+ 次):只测 helper 会假绿。
// 这里用**真工厂 + 真 loadOne**(经 Registry.Load),并断言
// ① 持久配置真的没被写 ② 插件真的被停/起。
import (
"os"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
isdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// fakeLifecyclePlugin 记录生命周期调用(真工厂产出的插件)。
type fakeLifecyclePlugin struct {
name string
starts int
stops int
}
func (p *fakeLifecyclePlugin) Name() string { return p.name }
func (p *fakeLifecyclePlugin) Start(s *isdk.PluginSDK) error {
p.starts++
return nil
}
func (p *fakeLifecyclePlugin) Stop() error {
p.stops++
return nil
}
// newRuntimeStateReg 建一个可真实加载 `alpha` 的 Registry(含临时数据目录)。
func newRuntimeStateReg(t *testing.T) (*Registry, string, *fakeLifecyclePlugin) {
t.Helper()
dir := t.TempDir()
plgDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(plgDir, 0o755); err != nil {
t.Fatal(err)
}
r := NewRegistry()
r.SetPluginDir(plgDir)
r.SetDataDir(dir)
// ★ 必须装一个**真** cfgReg:持久禁用状态就存在它里面,
// 而 isDisabled 在 cfgReg==nil 时恒返回 false(判据会假绿)。
// 这是"必须测真实入口"的又一具体落点。
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
t.Cleanup(func() { reg.Close() })
r.SetConfigRegistry(reg)
fp := &fakeLifecyclePlugin{name: "alpha"}
r.RegisterNative("alpha", func(name string, cfg map[string]interface{}) (isdk.Plugin, error) {
return fp, nil
})
return r, dir, fp
}
// ★★★ 判据:**StopPlugin 不写持久禁用配置**(运行态 ≠ 持久态)。
//
// 这是本组最核心的一条。若实现里误调了 DisablePlugin(或它内部的
// AddDisabledPlugin),用户下次开机会发现"我只是关了一下,怎么不启动了"。
func TestStopPluginDoesNotTouchPersistentState(t *testing.T) {
r, dir, fp := newRuntimeStateReg(t)
if err := r.Load(dir + "/plugins"); err != nil {
t.Fatal(err)
}
if fp.starts == 0 {
t.Fatal("前置失败:插件未被真实加载")
}
if err := r.StopPlugin("alpha"); err != nil {
t.Fatalf("StopPlugin: %v", err)
}
// ① 真的停了
if fp.stops == 0 {
t.Fatal("★ StopPlugin 没有真的停掉插件(只改了标记)")
}
// ② 运行态门置位
if !r.IsPluginRuntimeStopped("alpha") {
t.Fatal("StopPlugin 后应处于运行态关闭")
}
// ③ ★ 持久态**未变**:IsPluginDisabled 必须仍为 false
if r.IsPluginDisabled("alpha") {
t.Fatal("★ StopPlugin 写了持久禁用 —— 用户下次开机会发现插件不自启了" +
"(这正是要修的行为)")
}
}
// ★★★ 判据:**StartPlugin 不解除持久禁用**(且被持久禁用时拒绝)。
//
// 反向的误用:若 StartPlugin 调了 Enable(它内部 RemoveDisabledPlugin),
// 就会把用户"明确禁止这个插件"的决定悄悄改掉。
func TestStartPluginRefusesPersistentlyDisabled(t *testing.T) {
r, dir, _ := newRuntimeStateReg(t)
if err := r.Load(dir + "/plugins"); err != nil {
t.Fatal(err)
}
// 持久禁用(模拟用户的明确决定)
if err := r.DisablePlugin("alpha", "test"); err != nil {
t.Fatalf("DisablePlugin: %v", err)
}
if !r.IsPluginDisabled("alpha") {
t.Fatal("前置失败:应已持久禁用")
}
// ★ StartPlugin 必须**拒绝**(不能绕过用户的明确决定)。
//
// ★ 断言错误**原因**,不能只看 err != nil:
// 去掉我那句显式检查后,loadOne 里还有一道 isDisabled 门会挡住 ⇒
// StartPlugin 仍返回错误("启动插件失败")⇒ 只查非 nil 就**空转**了。
// 必须要求原因指向"持久禁用",才能区分"按预期拒绝"与"因别的原因失败"。
err := r.StartPlugin("alpha")
if err == nil {
t.Fatal("★ StartPlugin 加载了被持久禁用的插件 —— 绕过了用户的明确决定")
}
if !strings.Contains(err.Error(), "持久禁用") {
t.Fatalf("★ StartPlugin 拒绝的原因不是\"持久禁用\"(判据空转,无法区分真正原因): %v", err)
}
// 且必须仍处于持久禁用(不得被这次尝试清掉)
if !r.IsPluginDisabled("alpha") {
t.Fatal("★ StartPlugin 悄悄清除了持久禁用标记")
}
}
// ★★★ 判据:**StopPlugin → StartPlugin 能往返**(运行态关闭可被清除)。
func TestStopThenStartRoundTrip(t *testing.T) {
r, dir, fp := newRuntimeStateReg(t)
if err := r.Load(dir + "/plugins"); err != nil {
t.Fatal(err)
}
if err := r.StopPlugin("alpha"); err != nil {
t.Fatal(err)
}
if !r.IsPluginRuntimeStopped("alpha") {
t.Fatal("应处于运行态关闭")
}
// 启动:应清门并重新加载
startsBefore := fp.starts
if err := r.StartPlugin("alpha"); err != nil {
t.Fatalf("StartPlugin: %v", err)
}
if r.IsPluginRuntimeStopped("alpha") {
t.Fatal("★ StartPlugin 没有清掉运行态门(下次 loadOne 会被它挡住)")
}
if fp.starts <= startsBefore {
t.Fatal("★ StartPlugin 没有真的重新加载")
}
// 持久态全程未变
if r.IsPluginDisabled("alpha") {
t.Fatal("往返过程动了持久态")
}
}
// ★★★ 判据:**运行态关闭不持久**(重启后插件正常加载)。
//
// 这是有意设计:运行态关闭 = "这一次不跑",不是"以后都不跑"。
// 若它持久,agent 关掉一个插件后就再也没法让它随内核起来。
func TestRuntimeStopIsNotPersistent(t *testing.T) {
r, dir, _ := newRuntimeStateReg(t)
if err := r.Load(dir + "/plugins"); err != nil {
t.Fatal(err)
}
if err := r.StopPlugin("alpha"); err != nil {
t.Fatal(err)
}
if !r.IsPluginRuntimeStopped("alpha") {
t.Fatal("前置失败")
}
// 模拟"重启":新建一个 Registry(不共享任何内存状态)
r2, dir2, fp2 := newRuntimeStateReg(t)
_ = dir2
r2.SetPluginDir(r.PluginDir())
r2.RegisterNative("alpha", func(name string, cfg map[string]interface{}) (isdk.Plugin, error) {
return fp2, nil
})
if r2.IsPluginRuntimeStopped("alpha") {
t.Fatal("★ 运行态关闭跨实例保留了 —— 它必须是会话级的(重启即复位)")
}
// 重启后应能正常加载
if err := r2.Load(r.PluginDir()); err != nil {
t.Fatal(err)
}
if fp2.starts == 0 {
t.Fatal("★ 重启后被运行态关闭过的插件未能加载 —— " +
"agent 关掉它之后就再也起不来了(正是要修的毛病)")
}
}
// ★★★ 判据:**持久禁用仍走原来的门**(不能因新门而失效)。
//
// 持久禁用优先于运行态:被持久禁用的插件即使开始未运行态关闭,也不得加载。
func TestPersistentDisableStillBlocksLoad(t *testing.T) {
r, dir, fp := newRuntimeStateReg(t)
if err := r.DisablePlugin("alpha", "test"); err != nil {
t.Fatalf("DisablePlugin: %v", err)
}
if err := r.Load(dir + "/plugins"); err != nil {
t.Fatal(err)
}
if fp.starts != 0 {
t.Fatal("★ 持久禁用的插件被加载了(旧门失效)")
}
}
// ★★★ 判据:**pluginmgr 与 webui 不能被关闭/禁用**(防自锁)。
//
// 为何必须在内核层面拦:agent 可以犯错,而这两个操作一旦执行,
// agent **无法从自己这侧恢复**(要启动 pluginmgr 得先有 pluginmgr)。
func TestRefuseSelfLockOnCriticalPlugins(t *testing.T) {
r := NewRegistry()
// ★ 判据要断言三件事,不能只看 err != nil:
//
// 我第一次只查 `err == nil` ⇒ **空转**:去掉守卫后 StopPlugin 仍会报
// "plugin pluginmgr 未在运行"(因为没注册工厂),判据照样通过。
// 错误**原因**不同却无法区分,是判据失焦。
//
// 正确的断言:
// ① 返回错误 ② 错误信息点明"拒绝"(自锁守卫的措辞)
// ③ **状态未被改动**(这才是自锁的实质危害)
checkRefused := func(t *testing.T, err error, what string) {
t.Helper()
if err == nil {
t.Fatalf("★ 允许%s pluginmgr —— agent 无法从自己这侧恢复", what)
}
if !strings.Contains(err.Error(), "拒绝") {
t.Fatalf("★ %s 返回的错误不是自锁守卫给的(原因不明确,判据空转): %v", what, err)
}
}
checkRefused(t, r.StopPlugin("pluginmgr"), "运行态关闭")
// ③ 实质危害:门**不得**被置位
if r.IsPluginRuntimeStopped("pluginmgr") {
t.Fatal("★ 自锁守卫没拦住:pluginmgr 已被标记运行态关闭(它再也启动不回来了)")
}
checkRefused(t, r.DisablePlugin("pluginmgr", "agent"), "持久禁用")
// ③ 更糟的实质危害:持久禁用不得被写入
if r.IsPluginDisabled("pluginmgr") {
t.Fatal("★ 自锁守卫没拦住:pluginmgr 已被**持久禁用**(重启也不恢复)")
}
checkRefused(t, r.StopPlugin("webui"), "运行态关闭")
// 普通插件**不受**自锁守卫影响(守卫不能不问对象)(守卫不能误伤)
err := r.StopPlugin("someplugin")
if err == nil {
t.Fatal("未安装的普通插件应返回错误")
}
if strings.Contains(err.Error(), "拒绝") {
t.Fatalf("★ 自锁守卫误伤了普通插件: %v", err)
}
}
// ★★★ 判据:**StopPlugin 摸掉注册面**(否则旧闭包指向已死实例)。
//
// 与 ReloadOne/Disable 同一处置:不摸的后果是模型仍能看到旧工具并调用,
// 只能拿到 ErrProcessExited(通道/工具永久指向死实例)。
func TestStopPluginDetachesRegistrationSurface(t *testing.T) {
r, dir, _ := newRuntimeStateReg(t)
if err := r.Load(dir + "/plugins"); err != nil {
t.Fatal(err)
}
r.mu.Lock()
_, hadChannels := r.pluginChannels["alpha"]
_, hadSDK := r.sdkRefs["alpha"]
r.mu.Unlock()
if err := r.StopPlugin("alpha"); err != nil {
t.Fatal(err)
}
r.mu.Lock()
_, stillLoaded := r.plugins["alpha"]
_, stillSDK := r.sdkRefs["alpha"]
r.mu.Unlock()
if stillLoaded {
t.Fatal("★ StopPlugin 后插件仍在注册表里")
}
if stillSDK {
t.Fatal("★ StopPlugin 后 sdkRefs 未清 —— 旧 SDK 引用指向已死实例")
}
// 记录一下前置状态(避免 unused 警告同时留下诊断信息)
t.Logf("前置: channels=%v sdk=%v", hadChannels, hadSDK)
}