package lua import ( "os" "path/filepath" "runtime" "testing" "time" ) // ★ Close() 必须停掉每个插件的**调度循环**。 // // 真实缺陷(与 on_unload 同一形状的第 3 个实例):`Runtime.Stop()` 此前 // **只在单插件 Unload 里调**,Close() 不调 —— 于是「进程关闭/测试结束」 // 这条路径上,每个插件的 PluginRuntime.loop 永不停止。 // // 实测:全量回归在 internal/lua 出现 **69 个 goroutine 卡在 saver/runtime // 循环里**,最终 600s 超时。 // // 判据用的是**进程里真实存在的 goroutine 数**:每 LoadSource 一个插件就 // 起一个调度循环,Close 必须让它们全部退出 —— 否则 goroutine 只增不减。 // 这比"任务被调用过"更贴近缺陷本身(缺陷就是循环不停)。 func TestPluginsCloseStopsSchedulerGoroutines(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "plugins") if err := os.MkdirAll(pluginDir, 0755); err != nil { t.Fatal(err) } // 24 个带调度表的插件:每个起一个 PluginRuntime.loop const n = 24 for i := 0; i < n; i++ { src := ` local plugin = { name = "s` + string(rune('a'+i)) + `", version = "1.0.0", exports = {} } function plugin.noop_task() end plugin.schedule = { { fn = "noop_task", every = 3600 } } return plugin ` name := filepath.Join(pluginDir, "s"+string(rune('a'+i))+".lua") if err := os.WriteFile(name, []byte(src), 0644); err != nil { t.Fatal(err) } } before := runtime.NumGoroutine() ps := NewPlugins(nil, pluginDir) if err := ps.LoadDir(); err != nil { t.Fatalf("LoadDir: %v", err) } loaded := runtime.NumGoroutine() if loaded <= before { t.Fatalf("加载 %d 个带调度的插件后 goroutine 没涨(%d → %d)—— 夹具失效", n, before, loaded) } ps.Close() // ★ Close 之后循环必须**全部**退出,即回落到基线附近。 // // ⚠️ 这里不能用「比 loaded 小就算过」:那只要**一个** goroutine 退出就成立 // (27 → 26 也满足),而 24 个调度循环可以全部还在跑 —— 我第一版就是 // 这么写的,被变异测试当场揭穿(去掉 stopSchedulersAll 后仍然绿)。 // 判据必须锚定**基线**,容差只留给无关的运行时 goroutine。 deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if runtime.NumGoroutine() <= before+2 { break } time.Sleep(50 * time.Millisecond) } after := runtime.NumGoroutine() if after > before+2 { t.Fatalf("Close() 之后仍有调度循环在跑:goroutine 基线 %d、加载后 %d、Close 后 %d(加载了 %d 个插件)—— "+ "它们会一直堆积(实测全量回归堆到 69 个卡死并 600s 超时)", before, loaded, after, n) } t.Logf("✓ Close() 停掉了全部调度循环(goroutine 基线 %d → 加载后 %d → Close 后 %d,%d 个插件)", before, loaded, after, n) } // ★ Stop() 必须幂等:Unload 与 Close 都会停调度器,重复调不得 panic。 // // 反例:Stop 里裸 `close(rt.stop)` —— 第二次调用 `panic: close of closed channel`。 // 关闭路径上的 panic 最糟糕:它掩盖掉操作者本来在做的事(关服务/结束测试)。 func TestPluginRuntimeStopIsIdempotent(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 = "sched2", version = "1.0.0", exports = {} } function plugin.noop_task() end plugin.schedule = { { fn = "noop_task", every = 30 } } return plugin ` if err := os.WriteFile(filepath.Join(pluginDir, "sched2.lua"), []byte(src), 0644); err != nil { t.Fatal(err) } ps := NewPlugins(nil, pluginDir) if err := ps.LoadDir(); err != nil { t.Fatalf("LoadDir: %v", err) } defer ps.Close() ps.mu.RLock() var rt *PluginRuntime for _, p := range ps.plugins { if p.Info.Name == "sched2" { rt = p.Runtime } } ps.mu.RUnlock() if rt == nil { t.Fatal("插件未拿到 Runtime(夹具失效)") } // 连调三次:第二次若 panic,本测试就会崩掉(正是要防的行为) rt.Stop() rt.Stop() rt.Stop() } func waitForFile(t *testing.T, path string, d time.Duration) { t.Helper() deadline := time.Now().Add(d) for time.Now().Before(deadline) { if _, err := os.Stat(path); err == nil { return } time.Sleep(50 * time.Millisecond) } t.Fatalf("等待 %s 出现超时 —— 调度任务从未运行(夹具失效)", filepath.Base(path)) } func fileSize(t *testing.T, path string) int64 { t.Helper() st, err := os.Stat(path) if err != nil { t.Fatalf("stat %s: %v", path, err) } return st.Size() }