fix(plugin): 外部插件的 auto_start 从未生效 —— 补三态启动门

## 缺陷

`plugin.json` 的 `auto_start` 一路写得出来(hmapdev 打包会写),
但 **Load 的目录扫描从不读它**:`loadOne` 里只有持久禁用门与运行态门。
也就是说这个字段在**外部插件上完全不生效** —— 声明 `auto_start: false`
的插件照样开机自启。

(内置模块那条走 `kernelboot/scheduler.go` 的 `p.AutoStart`,与外部插件
是两条路,所以缺陷只暴露在外部插件上。)

## 三态语义(现在三条都成立)

	自动启动       auto_start 缺省/true  + 启动扫描   ⇒ 拉起
	启用但不自启   auto_start=false      + 启动扫描   ⇒ 跳过
	               同上                  + plugin_start ⇒ 拉起
	禁用           持久禁用清单          + 任何路径    ⇒ 拒绝(plugin_start 也拒)

★ `explicitStart` 是这次的关键设计点:`auto_start: false` 的含义是
"**别在开机时拉起我**",不是"永远不许启动"。少了这个区分,
按需加载的插件就再也起不来了 —— 比不声明更糟。

## 判据(4 条)

 · auto_start=false 在启动扫描时被拦(且不调用 Start)
 · 同一插件经显式启动**真的加载并 Start**(plugin_start 可用)
 · 未声明 auto_start ⇒ 自启(不误伤存量插件)
 · plugin.json 读不到 ⇒ 默认自启(清单损坏不该让插件静默消失)

## ★ 判据第一版是假绿(变异不咬)

第一版直接断言 `loadOneOpt` 返回 true/false。但 **false 有两个来源**:
① 被 auto_start 门拦住(正确)② 没有 plugin.bin 加载失败(噪音)。
两者无法区分 ⇒ 删掉 auto_start 门后判据照样通过。

改成用 `RegisterNative` 注册一个**真能 Start 的**假插件:
返回 true 必然意味着门没拦它,返回 false 必然意味着门拦了它,语义唯一。
修完两个变异都准确咬住(删门 / 忽略 explicitStart)。
This commit is contained in:
JianFeeeee
2026-10-18 11:24:14 +08:00
parent 53950e0388
commit c426921847
2 changed files with 201 additions and 1 deletions

View File

@ -0,0 +1,154 @@
package plugin
// 外部插件的三种启动形态(2026-10-18 补)。
//
// ## 缺陷
//
// `plugin.json` 的 `auto_start` 字段一路写得出来(hmapdev 打包会写),
// 但 **Load 的目录扫描从不读它**:`loadOne` 里只有持久禁用门与运行态门,
// 没有 auto_start 门。也就是说这个字段在外部插件上**完全不生效** ——
// 声明 `auto_start: false` 的插件照样开机自启。
//
// (内置模块那条走 kernelboot/scheduler.go 的 p.AutoStart,与外部插件是两条路,
// 所以这个缺陷只在外部插件上。)
//
// ## 三态语义(必须都成立)
//
// 自动启动 auto_start 缺省/true + 启动扫描 ⇒ 拉起
// 启用但不自启 auto_start=false + 启动扫描 ⇒ 跳过
// 同上 + plugin_start ⇒ 拉起(显式启动绕过)
// 禁用 持久禁用清单 + 任何路径 ⇒ 拒绝(连 plugin_start 也拒)
//
// ★ "启用但不自启" 必须能被 plugin_start 拉起:false 的含义是
// "别在开机时拉起我",不是"永远不许启动"。少了这个区分,
// 按需加载的插件就再也起不来了 —— 比不声明更糟。
//
// ## 判据方式
//
// 真起一个 Registry + 临时插件目录(含 plugin.json),断言三种情况的加载结果。
// 不用字符串匹配:那是"符号存在",不是"行为正确"。
import (
"os"
"path/filepath"
"testing"
isdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// writeProcPlugin 造一个最小的"外部插件目录"。
//
// 只写 plugin.json:本判据关心的是**是否被允许加载**,
// 而不是加载后能不能跑(那由 proc 层判据负责)。
// loadOne 在 auto_start 门处就会返回,不会走到真正的进程启动。
func writeProcPlugin(t *testing.T, root, name string, autoStart *bool) string {
t.Helper()
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
m := `{"name":"` + name + `","version":"0.0.1","entry":"plugin.bin"`
if autoStart != nil && !*autoStart {
m += `,"auto_start":false`
}
m += "}"
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(m), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// ★★★ 判据:auto_start=false 在启动扫描时被跳过,但显式启动能真的加载起来。
//
// ## 这一版为什么必须"真加载成功"
//
// 第一版只断言 loadOneOpt 返回 false/true。但 **false 有两个来源**:
//
// ① 被 auto_start 门拦住(正确)
// ② 根本没有 plugin.bin,加载失败(噪音)
//
// 两者无法区分 ⇒ 删掉 auto_start 门后判据照样"通过"(变异不咬,实测)。
//
// 修法:用 RegisterNative 注册一个**真能 Start 的**假插件,
// 这样 loadOneOpt 返回 true 就必然意味着"门没拦它",
// 返回 false 就必然意味着"门拦了它"。语义唯一。
func TestAutoStartFalseSkipsScanButAllowsExplicitStart(t *testing.T) {
root := t.TempDir()
f := false
dir := writeProcPlugin(t, root, "ondemand", &f)
newReg := func() (*Registry, *fakeLifecyclePlugin) {
r := NewRegistry()
r.SetPluginDir(root)
p := &fakeLifecyclePlugin{name: "ondemand"}
r.RegisterNative("ondemand", func(name string, cfg map[string]interface{}) (isdk.Plugin, error) {
p.name = name
return p, nil
})
return r, p
}
// ① 启动扫描路径(explicitStart=false)⇒ 必须被 auto_start 门拦住
r1, p1 := newReg()
if r1.loadOneOpt(dir, "ondemand", false) {
t.Fatal("★ auto_start=false 的插件在启动扫描时被加载了 —— " +
"该字段在外部插件上不生效(这是原始缺陷)")
}
if p1.starts != 0 {
t.Fatalf("★ 被拦住了但仍调用了 Start %d 次(门的位置不对)", p1.starts)
}
// ② 显式启动路径(explicitStart=true)⇒ 必须真的加载并 Start
r2, p2 := newReg()
if !r2.loadOneOpt(dir, "ondemand", true) {
t.Fatal("★ 显式启动(plugin_start)也被 auto_start 门拦住了 —— " +
"声明 false 是\"别开机自启\",不是\"永远不许启动\";" +
"按需插件会再也起不来")
}
if p2.starts != 1 {
t.Fatalf("★ 显式启动没有真的调用 Start(starts=%d)", p2.starts)
}
}
// ★★★ 判据:auto_start 缺省(未写)⇒ 启动扫描照常加载(不能误伤存量插件)。
func TestAutoStartDefaultTrueStillLoads(t *testing.T) {
root := t.TempDir()
dir := writeProcPlugin(t, root, "defaulton", nil)
r := NewRegistry()
r.SetPluginDir(root)
if !r.shouldAutoStart(dir, "defaulton") {
t.Fatal("★ 未声明 auto_start 的插件被判为不自启 —— " +
"存量插件会全部不启动(灾难性)")
}
}
// ★★★ 判据:auto_start=true 显式声明 ⇒ 自启。
func TestAutoStartExplicitTrueLoads(t *testing.T) {
root := t.TempDir()
tr := true
dir := writeProcPlugin(t, root, "explicit", &tr)
r := NewRegistry()
r.SetPluginDir(root)
if !r.shouldAutoStart(dir, "explicit") {
t.Fatal("★ auto_start=true 的插件被判为不自启")
}
}
// ★★★ 判据:manifest 读不到时**默认自启**(宁可多启,不要静默不启动)。
func TestAutoStartMissingManifestDefaultsTrue(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "nomanifest")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
r := NewRegistry()
r.SetPluginDir(root)
if !r.shouldAutoStart(dir, "nomanifest") {
t.Fatal("★ 读不到 plugin.json 时判为不自启 —— " +
"清单损坏会让插件静默不启动,比多启动一个更难查")
}
}

View File

@ -574,6 +574,21 @@ func pluginEntryHash(plgDir string) string {
} }
func (r *Registry) loadOne(plgDir, name string) bool { func (r *Registry) loadOne(plgDir, name string) bool {
return r.loadOneOpt(plgDir, name, false)
}
// loadOneOpt 是 loadOne 的显式版本。
//
// explicitStart=true 表示这是**agent 显式拉起**(plugin_start),
// 此时**忽略 auto_start**:插件声明"不自动启动"是"别在开机时拉起我",
// 不是"永远不许启动"。
//
// ★ 2026-10-18 补:在此之前**外部插件的 auto_start 完全不生效** ——
// Load 的目录扫描无条件调 loadOne,没有任何地方读 plugin.json 的
// auto_start。也就是说这个字段在 plg.json → plugin.json 一路写得出来,
// 内核却从不看它:声明 auto_start=false 的插件照样开机自启。
// (内置模块那条走的是 kernelboot/scheduler.go,与外部插件是两条路。)
func (r *Registry) loadOneOpt(plgDir, name string, explicitStart bool) bool {
if r.isDisabled(name) { if r.isDisabled(name) {
log.Printf("[plugin] %s is disabled, skipping", name) log.Printf("[plugin] %s is disabled, skipping", name)
r.mu.Lock() r.mu.Lock()
@ -593,6 +608,25 @@ func (r *Registry) loadOne(plgDir, name string) bool {
return false return false
} }
// ③ auto_start 门(2026-10-18 补:此前外部插件从不检查这个字段)。
//
// plugin.json 的 auto_start=false 表示"**启用**但不随内核自启",
// 用于按需加载的插件(纯工具型,没人调用时不该占进程与工具槽位)。
//
// 三种启动形态在这里交汇:
//
// 自动启动 auto_start 缺省/true + 启动扫描 ⇒ 拉起
// 启用但不自启 auto_start=false + 启动扫描 ⇒ **跳过**(本门)
// 同上 + plugin_start ⇒ 拉起(explicitStart)
// 禁用 持久禁用清单 ⇒ 上面的 isDisabled 门挡住,连 plugin_start 也拒
//
// ★ explicitStart 是关键:声明 false 是"别在开机时拉起我",
// 不是"永远不许启动"。没有这个区分,按需插件就再也起不来了。
if !explicitStart && !r.shouldAutoStart(plgDir, name) {
log.Printf("[plugin] %s 声明 auto_start=false(按需加载),跳过启动扫描;用 plugin_start 可拉起", name)
return false
}
// 1) 查找工厂(init 自注册或 RegisterNative) // 1) 查找工厂(init 自注册或 RegisterNative)
r.mu.RLock() r.mu.RLock()
factory, hasFactory := r.factories[name] factory, hasFactory := r.factories[name]
@ -1064,7 +1098,7 @@ func (r *Registry) ReloadOne(name string) error {
r.detachPlugin(name) r.detachPlugin(name)
r.closeDynamic(removed) r.closeDynamic(removed)
ok := r.loadOne(plgDir, name) ok := r.loadOneOpt(plgDir, name, true)
if !ok { if !ok {
return fmt.Errorf("reload plugin %s failed", name) return fmt.Errorf("reload plugin %s failed", name)
} }
@ -1278,6 +1312,18 @@ func (r *Registry) DisablePlugin(name, by string) error {
func (r *Registry) EnablePlugin(name string) error { return r.Enable(name) } func (r *Registry) EnablePlugin(name string) error { return r.Enable(name) }
// shouldAutoStart 读 plugin.json 的 auto_start(缺省 = true)。
//
// 读不到 manifest 时返回 true:宁可多启动一个(与旧行为一致),
// 也不要因为清单文件损坏而让插件静默不启动 —— 后者更难查。
func (r *Registry) shouldAutoStart(plgDir, name string) bool {
mft := readManifest(plgDir)
if mft == nil {
return true
}
return mft.ShouldAutoStart()
}
// isRuntimeStopped 报告插件是否处于**运行态关闭**(设计 §2.6.7.3)。 // isRuntimeStopped 报告插件是否处于**运行态关闭**(设计 §2.6.7.3)。
func (r *Registry) isRuntimeStopped(name string) bool { func (r *Registry) isRuntimeStopped(name string) bool {
r.mu.RLock() r.mu.RLock()