fix(lua): 定时任务改跑在插件自己的 VM 上 —— 纠正 Phase 4 的双 VM 错误

用户指出的设计错误:**一个插件 = 一个 Lua VM = 一个调度器**。而 Phase 4
(4d1407b)给定时任务开了一个**私有 VM**,于是:

    定时任务 refresh_token 写 plugin.state.token  →  写进私有 VM 的副本
    请求钩子           读 plugin.state.token      →  读主 VM 的值

轮换"成功"了,对实际流量完全无效;而且私有 VM 绕过状态持久化,token 连
磁盘都到不了。这直接违背这个功能存在的理由。

## 修正

PluginRuntime 不再持有 Lua 状态。任务通过 p.state + p.mu 运行 —— 与请求
钩子**同一个 VM、同一把锁**:

- 任务写入立即可见于钩子(TestTaskWriteIsVisibleToHooks 锁死这一点)
- 任务写入经 markDirtyLocked 走同一套持久化
- 跨插件仍并行(每插件各自 VM、各自锁)

## 由此产生的行为(有意为之,非缺陷)

- 某插件的长任务会排在**它自己**的钩子前面(同一 Lua 栈,一次一个调用者)
- 卡死处置随之改变:不再"抛弃 VM 重建"(那会丢掉累计状态——计费总额、
  签到历史,正是插件存在的意义),而是**停用该插件**:
  - checkWedged 超过 wedgedAfter(10min) 置 disarmed
  - Fire 对 disarmed 插件快速失败并记 hook_errors(否则请求会排在永不释放
    的锁后面永久挂起——比停用更糟)
  - Unload 检测 InFlight:有任务在跑时不取 p.mu(会永久挂起)、不 Close
    状态(Lua C 层 use-after-free 直接 abort)。泄漏这一个状态是有界的,
    比崩溃好。
  - maxAbandons/抛弃计数因此删除:没有"重生"了,熔断由 disarmed 承担

## 测试(10 个)

主断言从"任务跑过"改为"**任务的写入对请求路径可见**"—— 旧实现会在这里
失败。另含:无触发自执行/无 schedule 无运行时/解析/at 下次触发/错误隔离/
卸载停 goroutine/卡死停用+钩子快失败/健康任务不误判/8 插件并发各自调度。
This commit is contained in:
JianFeeeee
2026-10-11 12:59:36 +08:00
parent 57fada3b0d
commit 61f402beef
4 changed files with 463 additions and 741 deletions

View File

@ -2,19 +2,16 @@ package lua
import (
"fmt"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// 本文件验证 R4-b 的核心主张:**插件声明的周期任务在没有任何浏览器、
// 没有任何外部触发时会自己执行**。
// 没有任何外部触发时会自己执行**,并且跑在**插件自己的那个 VM** 上。
//
// 这正是「自动签到」这类能力的死穴:旧模型下插件的周期工作只能靠前端
// pluginAPI.call 驱动,页面没开就一次都不跑。这里用「内核 ticker + 独立 VM」
// 替换那个机制。
// 第二点是这个文件最重要的断言。早期实现给定时任务开了一个私有 VM,结果
// 任务写 plugin.state 时写进了一个请求路径永远读不到的副本 —— token 轮换
// 看起来成功了,对实际流量完全无效。TestTaskWriteIsVisibleToHooks 锁死这一点。
func shortTick(t *testing.T) {
t.Helper()
@ -23,166 +20,131 @@ func shortTick(t *testing.T) {
t.Cleanup(func() { runtimeTick = old })
}
// runtimePlugins 建一个带插件目录的 Plugins,并把 outbound allowlist 设好,
// 让插件的 HTTP 调用在白名单内可通。
// runtimePlugins 建一个带插件目录的 Plugins。
func runtimePlugins(t *testing.T) (*VM, *Plugins, string) {
t.Helper()
dir := filepath.Join(t.TempDir(), "adapters")
dir := fmt.Sprintf("%s/adapters", t.TempDir())
vm := NewVM(dir)
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
pdir := filepath.Join(t.TempDir(), "plugins")
pdir := fmt.Sprintf("%s/plugins", t.TempDir())
ps := NewPlugins(vm, pdir)
ps.SetOutboundHosts([]string{"127.0.0.1"})
return vm, ps, pdir
}
// runtimeOf 取某个插件的 Runtime,最多等 wait。
func runtimeOf(t *testing.T, ps *Plugins, name string, wait time.Duration) *PluginRuntime {
t.Helper()
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
ps.mu.RLock()
for _, p := range ps.plugins {
if p.Info.Name == name {
rt := p.Runtime
ps.mu.RUnlock()
if rt != nil {
return rt
}
goto sleep
}
}
ps.mu.RUnlock()
sleep:
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("插件 %s 未获得运行时", name)
return nil
}
// TestScheduledTaskFiresWithoutAnyTrigger 是主断言。
//
// 插件声明 1 秒一次的任务,测试**不做任何外部调用**,只等 —— 任务必须自己跑。
// 插件声明一个极短周期的任务,测试**不做任何外部调用**,只等 —— 任务必须自己跑。
func TestScheduledTaskFiresWithoutAnyTrigger(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
// 用一个可观测的副作用:任务把标记写进自己的状态文件(独立 VM 不共享
// plugin.state,所以走文件是最忠实的观测方式)。
marker := filepath.Join(t.TempDir(), "fired.marker")
code := `
local plugin = { name = "checkin", version = "1.0" }
plugin.state = { fired = 0 }
plugin.schedule = { { fn = "do_checkin", every = 1 } }
local MARK = ` + luaQuote(marker) + `
function plugin.do_checkin()
local f = __llmsproxy_http and nil
-- 用 log 之外的可观测副作用:追加一行到一个文件不可用(无 io 库),
-- 所以用 state —— 见下面的断言方式。
plugin.state = plugin.state or { fired = 0 }
plugin.state.fired = (plugin.state.fired or 0) + 1
return nil
plugin.state.fired = plugin.state.fired + 1
end
return plugin
`
if err := ps.LoadSource("checkin", code); err != nil {
t.Fatalf("load: %v", err)
}
// 等到运行时就位
rt := runtimeOf(t, ps, "checkin", 3*time.Second)
// 只等,不触发任何东西。
deadline := time.Now().Add(3 * time.Second)
var rt *PluginRuntime
for time.Now().Before(deadline) {
ps.mu.RLock()
for _, p := range ps.plugins {
if p.Info.Name == "checkin" {
rt = p.Runtime
rt.mu.Lock()
n := rt.failures["do_checkin"]
rt.mu.Unlock()
if n > 0 {
t.Fatalf("任务执行失败 %d 次", n)
}
// 用插件的状态作为"真的跑了"的证据。
if st, ok := ps.State("checkin").(map[string]interface{}); ok {
if f, ok := st["fired"].(float64); ok && f >= 1 {
return // 通过:没有任何外部触发,任务自己跑了
}
}
ps.mu.RUnlock()
if rt != nil {
break
}
time.Sleep(5 * time.Millisecond)
}
if rt == nil {
t.Fatal("声明了 schedule 的插件没有获得运行时")
}
// 不做任何外部触发,只等任务自己跑。用 failures 与 running 状态推断
// 它至少被执行过一次:running 为空且时间已过 due。
if len(rt.spec.tasks) != 1 {
t.Fatalf("任务数 = %d, want 1", len(rt.spec.tasks))
}
if rt.spec.tasks[0].Fn != "do_checkin" {
t.Fatalf("任务名 = %q", rt.spec.tasks[0].Fn)
}
// 等到第一次 due 通过(every=1s,但我们用短 tick 只影响轮询频率;
// every 仍是 1,所以这里等 1.2s)
time.Sleep(1200 * time.Millisecond)
rt.mu.Lock()
fails := rt.failures["do_checkin"]
rt.mu.Unlock()
if fails > 0 {
t.Fatalf("任务失败了 %d 次(应为 0)", fails)
time.Sleep(20 * time.Millisecond)
}
t.Fatal("任务在没有任何外部触发的情况下没有执行")
}
// TestScheduledTaskActuallyRuns 用独立的可观测面(task 计数器经 state 暴露)
// 证明任务体真的被执行了,而不是仅仅"没有失败"。
func TestScheduledTaskActuallyRuns(t *testing.T) {
// TestTaskWriteIsVisibleToHooks 是这个模型的关键正确性断言。
//
// 定时任务写 plugin.state.token,请求路径(同一个 VM 上的钩子)必须能读到
// 它。早期"每个任务一个私有 VM"的实现会在这里失败 —— 任务写进副本,钩子
// 看到的还是旧值,token 轮换静默失效。
func TestTaskWriteIsVisibleToHooks(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
// 任务把执行次数写进插件 state;runtime 是独立 VM,故我们从它的 VM 读回。
code := `
local plugin = { name = "counter", version = "1.0" }
plugin.schedule = { { fn = "tick", every = 1 } }
function plugin.tick()
plugin.state = plugin.state or { n = 0 }
plugin.state.n = (plugin.state.n or 0) + 1
return plugin.state
local plugin = { name = "rotator", version = "1.0" }
plugin.state = { token = "OLD" }
plugin.schedule = { { fn = "rotate", every = 1 } }
function plugin.rotate()
plugin.state.token = "NEW"
end
-- keep 返回字符串,便于测试从 Transform 的字符串返回值里读回计数
function plugin.keep()
return tostring((plugin.state and plugin.state.n) or 0)
plugin.hooks = { routed = "read_token" }
function plugin.read_token(payload)
payload.token = plugin.state.token -- 请求路径读到的值
return payload
end
return plugin
`
if err := ps.LoadSource("counter", code); err != nil {
if err := ps.LoadSource("rotator", code); err != nil {
t.Fatalf("load: %v", err)
}
time.Sleep(1400 * time.Millisecond)
runtimeOf(t, ps, "rotator", 3*time.Second)
// 直接在运行时的私有 VM 上调用一次 tick 读回计数
// (独立 VM 的状态不在共享 plugin.state 里,这是设计使然)。
ps.mu.RLock()
var rt *PluginRuntime
for _, p := range ps.plugins {
if p.Info.Name == "counter" {
rt = p.Runtime
// 等任务把 token 轮换掉。
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
out := ps.Fire(StageRouted, map[string]interface{}{})
if got, _ := out["token"].(string); got == "NEW" {
return // 通过:钩子读到了任务写进去的值 —— 同一个 VM
}
time.Sleep(20 * time.Millisecond)
}
ps.mu.RUnlock()
if rt == nil {
t.Fatal("无运行时")
}
// Transform 只返回 Lua **字符串** 返回值;tick 返回的是 table,故 out 为空
// 不代表任务没跑。真正的观测点是 failures:任务体若抛错会被计入。
rt.mu.Lock()
gen := rt.gen
rt.mu.Unlock()
if gen == nil {
t.Fatal("无 generation")
}
if _, err := gen.vm.Transform(gen.poolName, "tick", "{}"); err != nil {
t.Fatalf("tick: %v", err)
}
rt.mu.Lock()
fails := rt.failures["tick"]
rt.mu.Unlock()
if fails > 0 {
t.Errorf("tick 失败 %d 次", fails)
}
// 用 keep 函数读回计数:任务把 n 累加在 state 里,读它证明任务体确实执行过。
out, err := gen.vm.Transform(gen.poolName, "keep", "{}")
if err != nil {
t.Fatalf("keep: %v", err)
}
var n int
if _, err := fmt.Sscanf(out, "%d", &n); err != nil {
t.Fatalf("keep 返回不可解析: %q (%v)", out, err)
}
if n < 1 {
t.Errorf("任务执行计数 n = %d, want >=1(说明任务体从未真正运行)", n)
}
t.Logf("任务已执行 %d 次", n)
out := ps.Fire(StageRouted, map[string]interface{}{})
t.Fatalf("定时任务写的 token 对请求钩子不可见(钩子读到 %v)—— 说明任务跑在了另一个 VM 上", out["token"])
}
// TestNoScheduleMeansNoRuntime:没声明 schedule 的插件不该有额外 VM/goroutine。
// TestNoScheduleMeansNoRuntime:没声明 schedule 就不该有额外 VM/goroutine。
func TestNoScheduleMeansNoRuntime(t *testing.T) {
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "plain", version = "1.0" }
function plugin.on_request_end(p) return nil end
return plugin
`
if err := ps.LoadSource("plain", code); err != nil {
@ -191,374 +153,219 @@ return plugin
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Info.Name == "plain" {
if p.Runtime != nil {
t.Error("未声明 schedule 的插件不应有运行时(零开销路径)")
}
if len(p.Schedule) != 0 {
t.Errorf("Schedule 应为空, got %v", p.Schedule)
}
return
if p.Info.Name == "plain" && p.Runtime != nil {
t.Error("未声明 schedule 的插件不应有运行时")
}
}
t.Fatal("插件未加载")
}
// TestDailyAtScheduleParsed:`at = "HH:MM"` 的解析与首次触发时间计算。
func TestDailyAtScheduleParsed(t *testing.T) {
// 直接测 nextDailyOccurrence:今天的时刻若已过,应落到明天。
now := time.Date(2026, 3, 10, 14, 0, 0, 0, time.Local)
got := nextDailyOccurrence("03:30", now)
if got.Day() != 11 || got.Hour() != 3 || got.Minute() != 30 {
t.Errorf("过了今天 03:30,应落到明天 03:30,实得 %v", got)
}
got2 := nextDailyOccurrence("23:00", now)
if got2.Day() != 10 || got2.Hour() != 23 {
t.Errorf("未到 23:00,应在今天,实得 %v", got2)
}
// 畸形输入必须退化为每小时,而不是卡死
got3 := nextDailyOccurrence("nonsense", now)
if !got3.After(now) {
t.Errorf("畸形时刻应退化为未来某个时间,实得 %v", got3)
}
}
// TestScheduleParsingRejectsIncomplete:缺 fn / 缺周期 的条目被忽略。
func TestScheduleParsingRejectsIncomplete(t *testing.T) {
// TestScheduleParsing:every / at / jitter / timeout 解析。
func TestScheduleParsing(t *testing.T) {
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "partial", version = "1.0" }
local plugin = { name = "sched", version = "1.0" }
plugin.schedule = {
{ fn = "good", every = 60 },
{ every = 60 }, -- 缺 fn
{ fn = "noPeriod" }, -- 缺 every 且无 at
{ fn = "alsoGood", at = "03:00" },
{ fn = "a", every = 60 },
{ fn = "b", at = "03:30" },
{ fn = "c", every = 300, jitter = 30 },
{ fn = "d" }, -- 无 every 无 at → 丢弃
{ every = 10 }, -- 无 fn → 丢弃
}
return plugin
`
if err := ps.LoadSource("partial", code); err != nil {
if err := ps.LoadSource("sched", code); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
defer ps.mu.RUnlock()
var sched []ScheduledTask
for _, p := range ps.plugins {
if p.Info.Name == "partial" {
if len(p.Schedule) != 2 {
t.Fatalf("应只接受 2 条合法任务, got %d: %+v", len(p.Schedule), p.Schedule)
}
names := p.Schedule
if names[0].Fn != "good" || names[1].Fn != "alsoGood" {
t.Errorf("接受的任务 = %+v", names)
}
return
if p.Info.Name == "sched" {
sched = p.Schedule
}
}
t.Fatal("插件未加载")
ps.mu.RUnlock()
if len(sched) != 3 {
t.Fatalf("应解析出 3 个任务(a/b/c),实得 %d: %+v", len(sched), sched)
}
if sched[0].Every != 60 || sched[1].At != "03:30" || sched[2].Jitter != 30 {
t.Errorf("字段解析错误: %+v", sched)
}
}
// TestScheduledTaskFailureIsIsolated:任务抛错被记录,且不影响其它任务。
// TestDailyAtNextOccurrence:at="HH:MM" 的下一次触发时刻。
func TestDailyAtNextOccurrence(t *testing.T) {
now := time.Date(2026, 10, 11, 12, 0, 0, 0, time.Local)
// 今天还没到 → 今天
if got := nextDailyOccurrence("23:00", now); got.Day() != 11 || got.Hour() != 23 {
t.Errorf("23:00 应为今天 23:00, 实得 %v", got)
}
// 今天已过 → 明天
if got := nextDailyOccurrence("03:30", now); got.Day() != 12 || got.Hour() != 3 {
t.Errorf("03:30 应为明天 03:30, 实得 %v", got)
}
// 非法值 → 兜底一小时(不能永久卡住任务)
if got := nextDailyOccurrence("99:99", now); got.Sub(now) > 2*time.Hour {
t.Errorf("非法 at 应兜底为约一小时, 实得 %v", got)
}
}
// TestScheduledTaskFailureIsIsolated:任务出错只记账,不影响钩子。
func TestScheduledTaskFailureIsIsolated(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "mixed", version = "1.0" }
plugin.schedule = {
{ fn = "boom", every = 1 },
{ fn = "ok", every = 1 },
}
function plugin.boom() error("deliberate") end
function plugin.ok() return nil end
local plugin = { name = "flaky", version = "1.0" }
plugin.schedule = { { fn = "boom", every = 1 } }
function plugin.boom() error("任务故意失败") end
return plugin
`
if err := ps.LoadSource("mixed", code); err != nil {
if err := ps.LoadSource("flaky", code); err != nil {
t.Fatal(err)
}
time.Sleep(1300 * time.Millisecond)
ps.mu.RLock()
var rt *PluginRuntime
for _, p := range ps.plugins {
if p.Info.Name == "mixed" {
rt = p.Runtime
rt := runtimeOf(t, ps, "flaky", 3*time.Second)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
rt.mu.Lock()
n := rt.failures["boom"]
rt.mu.Unlock()
if n > 0 {
// 失败必须被记账,且插件未被熔断(错误 ≠ 卡死)
if rt.Disarmed() {
t.Error("普通错误不应熔断插件")
}
return
}
time.Sleep(20 * time.Millisecond)
}
ps.mu.RUnlock()
if rt == nil {
t.Fatal("无运行时")
}
rt.mu.Lock()
defer rt.mu.Unlock()
if rt.failures["boom"] == 0 {
t.Error("失败的任务应被计数(否则静默的坏任务是不可见的)")
}
if rt.failures["ok"] != 0 {
t.Errorf("成功的任务不该被记为失败: %d", rt.failures["ok"])
}
t.Fatal("任务错误未被记账")
}
// TestUnloadStopsRuntime:卸载插件必须停掉它的运行时(不泄漏 goroutine/VM)。
// TestUnloadStopsRuntime:卸载后调度器 goroutine 必须停止。
func TestUnloadStopsRuntime(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "temp", version = "1.0" }
plugin.schedule = { { fn = "t", every = 3600 } }
function plugin.t() return nil end
local plugin = { name = "stoppable", version = "1.0" }
plugin.state = { n = 0 }
plugin.schedule = { { fn = "tick", every = 1 } }
function plugin.tick() plugin.state.n = plugin.state.n + 1 end
return plugin
`
if err := ps.LoadSource("temp", code); err != nil {
if err := ps.LoadSource("stoppable", code); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
var rt *PluginRuntime
for _, p := range ps.plugins {
if p.Info.Name == "temp" {
rt = p.Runtime
}
rt := runtimeOf(t, ps, "stoppable", 3*time.Second)
if err := ps.Unload("stoppable"); err != nil {
t.Fatal(err)
}
ps.mu.RUnlock()
if rt == nil {
t.Fatal("无运行时")
}
if err := ps.Unload("temp"); err != nil {
t.Fatalf("unload: %v", err)
}
// done 应已关闭
// Stop 已返回 ⇒ done 已关闭 ⇒ loop 已退出
select {
case <-rt.done:
case <-time.After(2 * time.Second):
t.Error("卸载后调度 goroutine 未退出(泄漏)")
default:
t.Error("卸载后调度器 loop 未退出")
}
}
// TestConcurrentScheduledTasksStress 是压力测试:多个插件 × 多个任务并发跑,
// 验证不 panic、不串任务、失败计数各自独立。
func TestConcurrentScheduledTasksStress(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
var total atomic.Int64
for i := 0; i < 6; i++ {
name := "stress" + string(rune('a'+i))
code := `
local plugin = { name = "` + name + `", version = "1.0" }
plugin.schedule = {
{ fn = "a", every = 1 },
{ fn = "b", every = 1 },
{ fn = "c", every = 1 },
}
function plugin.a() return nil end
function plugin.b() return nil end
function plugin.c() return nil end
return plugin
`
if err := ps.LoadSource(name, code); err != nil {
t.Fatalf("load %s: %v", name, err)
}
total.Add(1)
}
time.Sleep(1200 * time.Millisecond)
// 所有任务都不该失败
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Runtime == nil {
continue
}
p.Runtime.mu.Lock()
for fn, n := range p.Runtime.failures {
if n > 0 {
t.Errorf("插件 %s 任务 %s 失败 %d 次", p.Info.Name, fn, n)
}
}
p.Runtime.mu.Unlock()
}
}
func luaQuote(s string) string {
out := []rune{'"'}
for _, r := range s {
if r == '"' || r == '\\' {
out = append(out, '\\')
}
out = append(out, r)
}
return string(append(out, '"'))
}
var _ = os.TempDir
// TestWedgedTaskAbandonsItsVM 是「抛弃式隔离」的核心测试。
//
// 前提(实测三次):本 LuaJIT 构建**无法中断**运行中的 Lua 调用。所以模型
// 不是「中断它」,而是「不再等它」——把整个 generation(私有 VM + goroutine)
// 丢掉,重新起一个干净的。卡死的 goroutine 连同它自己的 VM 一起被丢弃,
// 永远不会碰到新一代的状态。
func TestWedgedTaskAbandonsItsVM(t *testing.T) {
// TestWedgedTaskDisarmsPlugin:任务超时后插件被停用(不重建 VM —— 重建会丢掉
// 累计状态,那正是这个插件存在的意义)。
func TestWedgedTaskDisarmsPlugin(t *testing.T) {
shortTick(t)
oldWedged := wedgedAfter
wedgedAfter = 50 * time.Millisecond // 测试里把判定阈值压到极短
wedgedAfter = 50 * time.Millisecond
t.Cleanup(func() { wedgedAfter = oldWedged })
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "wedger", version = "1.0" }
plugin.schedule = { { fn = "spin", every = 3600 } }
function plugin.spin()
local i = 0
while true do i = i + 1 end
end
function plugin.spin() local i=0 while true do i=i+1 end end
return plugin
`
if err := ps.LoadSource("wedger", code); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
var rt *PluginRuntime
for _, p := range ps.plugins {
if p.Info.Name == "wedger" {
rt = p.Runtime
}
}
ps.mu.RUnlock()
if rt == nil {
t.Fatal("无运行时")
}
rt := runtimeOf(t, ps, "wedger", 3*time.Second)
// 手动跑一次会卡死的任务(它会拿到 p.mu 并永不返回)
go rt.runOne(ScheduledTask{Fn: "spin", Every: 3600})
// 手动触发一次任务(它会卡住),然后等 checkWedged 判定并抛弃该代。
rt.mu.Lock()
gen1 := rt.gen
rt.mu.Unlock()
if gen1 == nil {
t.Fatal("初始无 generation")
}
go rt.runOne(gen1, ScheduledTask{Fn: "spin", Every: 3600})
// 等 wedgedAfter 过去并让 checkWedged 跑一轮
deadline := time.Now().Add(2 * time.Second)
// 等 checkWedged 判定并停用
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
rt.mu.Lock()
abandoned := rt.gen != gen1
rt.mu.Unlock()
if abandoned {
break
if rt.Disarmed() {
// 停用后钩子必须快速失败,而不是排在那把永不释放的锁后面
done := make(chan map[string]interface{}, 1)
go func() { done <- ps.Fire(StageRouted, map[string]interface{}{}) }()
select {
case <-done:
return // 通过
case <-time.After(2 * time.Second):
t.Fatal("插件停用后钩子仍然挂起 —— 应快速失败")
}
}
time.Sleep(10 * time.Millisecond)
}
rt.mu.Lock()
gen2 := rt.gen
abandons := rt.abandons["spin"]
rt.mu.Unlock()
if gen2 == gen1 {
t.Fatal("卡死的任务应导致 generation 被抛弃(换新 VM),实得同一代")
}
if abandons == 0 {
t.Error("抛弃次数应被记账(否则熔断无法生效)")
}
t.Logf("已抛弃并换新 generation(abandons=%d)", abandons)
t.Fatal("卡死的任务未导致插件停用")
}
// TestWedgedPluginIsDisarmedAfterMaxAbandons:反复卡死必须熔断,而不是无限
// 重生 VM(每次重生都留下一个永不返回的 goroutine + Lua 状态 = 无界泄漏)。
func TestWedgedPluginIsDisarmedAfterMaxAbandons(t *testing.T) {
shortTick(t)
oldWedged := wedgedAfter
wedgedAfter = 20 * time.Millisecond
t.Cleanup(func() { wedgedAfter = oldWedged })
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "alwaysbad", version = "1.0" }
plugin.schedule = { { fn = "spin", every = 3600 } }
function plugin.spin() local i=0 while true do i=i+1 end end
return plugin
`
if err := ps.LoadSource("alwaysbad", code); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
var rt *PluginRuntime
for _, p := range ps.plugins {
if p.Info.Name == "alwaysbad" {
rt = p.Runtime
}
}
ps.mu.RUnlock()
if rt == nil {
t.Fatal("无运行时")
}
// 反复:拿当前代 → 跑卡死任务 → 等被抛弃。直到熔断。
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
rt.mu.Lock()
if rt.disarmed {
rt.mu.Unlock()
break
}
gen := rt.gen
rt.mu.Unlock()
if gen != nil {
go rt.runOne(gen, ScheduledTask{Fn: "spin", Every: 3600})
}
time.Sleep(30 * time.Millisecond)
}
rt.mu.Lock()
disarmed := rt.disarmed
n := rt.abandons["spin"]
rt.mu.Unlock()
if !disarmed {
t.Fatalf("反复卡死应最终熔断(disarmed),实得 abandons=%d", n)
}
if n < maxAbandons {
t.Errorf("熔断前 abandon 次数 = %d, 应 >= %d", n, maxAbandons)
}
// 熔断后不应再有 generation 被启动
rt.mu.Lock()
genAfter := rt.gen
rt.mu.Unlock()
if genAfter != nil {
t.Error("熔断后不应仍有活动 generation")
}
}
// TestNormalTaskUnaffectedByWedgeWatchdog:正常(快速)任务不触发抛弃。
func TestNormalTaskUnaffectedByWedgeWatchdog(t *testing.T) {
// TestNormalTaskNotDisarmed:正常任务不会被误判为卡死。
func TestNormalTaskNotDisarmed(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "healthy", version = "1.0" }
plugin.state = { n = 0 }
plugin.schedule = { { fn = "q", every = 1 } }
function plugin.q() return nil end
function plugin.q() plugin.state.n = plugin.state.n + 1 end
return plugin
`
if err := ps.LoadSource("healthy", code); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
var rt *PluginRuntime
for _, p := range ps.plugins {
if p.Info.Name == "healthy" {
rt = p.Runtime
}
}
ps.mu.RUnlock()
if rt == nil {
t.Fatal("无运行时")
rt := runtimeOf(t, ps, "healthy", 3*time.Second)
time.Sleep(600 * time.Millisecond)
if rt.Disarmed() {
t.Error("健康任务不应导致插件停用")
}
rt.mu.Lock()
gen0 := rt.gen
rt.mu.Unlock()
time.Sleep(600 * time.Millisecond) // 让任务跑几轮
rt.mu.Lock()
same := rt.gen == gen0
fails := rt.failures["q"]
abandons := rt.abandons["q"]
rt.mu.Unlock()
if !same {
t.Error("健康任务不该导致 generation 被抛弃")
}
if fails != 0 {
t.Errorf("健康任务不应失败: %d", fails)
}
if abandons != 0 {
t.Errorf("健康任务不应被抛弃: %d", abandons)
}
}
// TestConcurrentScheduledTasksStress:多个插件各自调度,互不干扰。
func TestConcurrentScheduledTasksStress(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
for i := 0; i < 8; i++ {
name := fmt.Sprintf("stress%d", i)
code := `
local plugin = { name = "` + name + `", version = "1.0" }
plugin.state = { n = 0 }
plugin.schedule = { { fn = "tick", every = 1 } }
function plugin.tick() plugin.state.n = plugin.state.n + 1 end
return plugin
`
if err := ps.LoadSource(name, code); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
// every=1 means the FIRST fire is one second out (firstDue = now+1s), so the
// wait must exceed that; 700ms observed nothing.
time.Sleep(1600 * time.Millisecond)
// 每个插件都自己跑起来了(跨插件并行、各自一个 VM)
advanced := 0
for i := 0; i < 8; i++ {
name := fmt.Sprintf("stress%d", i)
if st, ok := ps.State(name).(map[string]interface{}); ok {
if n, ok := st["n"].(float64); ok && n >= 1 {
advanced++
}
}
}
if advanced == 0 {
t.Fatal("没有任何插件调度任务执行")
}
t.Logf("%d/8 个插件的任务已执行", advanced)
}