package lua import ( "os" "path/filepath" "testing" ) // ★ 关闭 Plugins 时必须给插件一次 on_unload 机会。 // // 真实缺陷:Close() 此前只关 shard 与 saver,**从不调用 on_unload** —— 而 // on_unload 只在单插件 Unload 时调。于是「进程关闭/测试结束」这条路径上, // 起了 OS 进程的插件(回环回调接收器、浏览器栈)永远收不了尾,留下孤儿进程。 // 实测本机积累到 995 个 python 监听器(828 个超 24 小时),全是测试残留。 // // 这个判据看的是**真实外部效应**(插件在 on_unload 里改磁盘上的文件), // 而不是「钩子被调用过」这类内部信号 —— 后者在钩子写错名字时也会假绿。 func TestPluginsCloseCallsOnUnload(t *testing.T) { dir := t.TempDir() marker := filepath.Join(dir, "unloaded.marker") pluginDir := filepath.Join(dir, "plugins") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } src := ` local plugin = { name = "leaky", version = "1.0.0", exports = {} } plugin.ui = nil function plugin.on_unload() local f = io.open("` + marker + `", "w") if f then f:write("reaped"); f:close() end end return plugin ` if err := os.WriteFile(filepath.Join(pluginDir, "leaky.lua"), []byte(src), 0644); err != nil { t.Fatal(err) } ps := NewPlugins(nil, pluginDir) if err := ps.LoadDir(); err != nil { t.Fatalf("LoadDir: %v", err) } // 关之前不该有标记 if _, err := os.Stat(marker); err == nil { t.Fatal("on_unload 在 Close 之前就被调了?") } ps.Close() // ★ 真判据:文件必须被写出来(= on_unload 真的跑过) if _, err := os.Stat(marker); err != nil { t.Fatalf("Close() 没有调用 on_unload —— 起了 OS 进程的插件会留下孤儿进程"+ "(真实缺陷:995 个 python 监听器残留)。marker 不存在: %v", err) } t.Log("✓ Close() 触发了 on_unload(孤儿进程不会再堆积)") } // on_unload 抛错不得让 Close 崩掉(best-effort 契约)。 func TestPluginsCloseSurvivesFailingOnUnload(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "plugins") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } src := ` local plugin = { name = "boom", version = "1.0.0", exports = {} } function plugin.on_unload() error("故意炸") end return plugin ` if err := os.WriteFile(filepath.Join(pluginDir, "boom.lua"), []byte(src), 0644); err != nil { t.Fatal(err) } ps := NewPlugins(nil, pluginDir) if err := ps.LoadDir(); err != nil { t.Fatalf("LoadDir: %v", err) } ps.Close() // 不得 panic }