package lua import ( "fmt" "os" "path/filepath" "sync/atomic" "testing" "time" ) // 本文件验证 R4-b 的核心主张:**插件声明的周期任务在没有任何浏览器、 // 没有任何外部触发时会自己执行**。 // // 这正是「自动签到」这类能力的死穴:旧模型下插件的周期工作只能靠前端 // pluginAPI.call 驱动,页面没开就一次都不跑。这里用「内核 ticker + 独立 VM」 // 替换那个机制。 func shortTick(t *testing.T) { t.Helper() old := runtimeTick runtimeTick = 10 * time.Millisecond t.Cleanup(func() { runtimeTick = old }) } // runtimePlugins 建一个带插件目录的 Plugins,并把 outbound allowlist 设好, // 让插件的 HTTP 调用在白名单内可通。 func runtimePlugins(t *testing.T) (*VM, *Plugins, string) { t.Helper() dir := filepath.Join(t.TempDir(), "adapters") vm := NewVM(dir) if err := vm.Start(); err != nil { t.Fatal(err) } t.Cleanup(vm.Stop) pdir := filepath.Join(t.TempDir(), "plugins") ps := NewPlugins(vm, pdir) ps.SetOutboundHosts([]string{"127.0.0.1"}) return vm, ps, pdir } // 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.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 end return plugin ` if err := ps.LoadSource("checkin", code); err != nil { t.Fatalf("load: %v", err) } // 等到运行时就位 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 } } 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) } } // TestScheduledTaskActuallyRuns 用独立的可观测面(task 计数器经 state 暴露) // 证明任务体真的被执行了,而不是仅仅"没有失败"。 func TestScheduledTaskActuallyRuns(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 end -- keep 返回字符串,便于测试从 Transform 的字符串返回值里读回计数 function plugin.keep() return tostring((plugin.state and plugin.state.n) or 0) end return plugin ` if err := ps.LoadSource("counter", code); err != nil { t.Fatalf("load: %v", err) } time.Sleep(1400 * time.Millisecond) // 直接在运行时的私有 VM 上调用一次 tick 读回计数 // (独立 VM 的状态不在共享 plugin.state 里,这是设计使然)。 ps.mu.RLock() var rt *PluginRuntime for _, p := range ps.plugins { if p.Info.Name == "counter" { rt = p.Runtime } } 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) } // 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 { t.Fatal(err) } 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 } } 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) { _, ps, _ := runtimePlugins(t) code := ` local plugin = { name = "partial", version = "1.0" } plugin.schedule = { { fn = "good", every = 60 }, { every = 60 }, -- 缺 fn { fn = "noPeriod" }, -- 缺 every 且无 at { fn = "alsoGood", at = "03:00" }, } return plugin ` if err := ps.LoadSource("partial", code); err != nil { t.Fatal(err) } ps.mu.RLock() defer ps.mu.RUnlock() 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 } } t.Fatal("插件未加载") } // 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 return plugin ` if err := ps.LoadSource("mixed", 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 } } 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"]) } } // TestUnloadStopsRuntime:卸载插件必须停掉它的运行时(不泄漏 goroutine/VM)。 func TestUnloadStopsRuntime(t *testing.T) { _, ps, _ := runtimePlugins(t) code := ` local plugin = { name = "temp", version = "1.0" } plugin.schedule = { { fn = "t", every = 3600 } } function plugin.t() return nil end return plugin ` if err := ps.LoadSource("temp", 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 } } ps.mu.RUnlock() if rt == nil { t.Fatal("无运行时") } if err := ps.Unload("temp"); err != nil { t.Fatalf("unload: %v", err) } // done 应已关闭 select { case <-rt.done: case <-time.After(2 * time.Second): t.Error("卸载后调度 goroutine 未退出(泄漏)") } } // 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) { shortTick(t) oldWedged := wedgedAfter 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 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("无运行时") } // 手动触发一次任务(它会卡住),然后等 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) for time.Now().Before(deadline) { rt.mu.Lock() abandoned := rt.gen != gen1 rt.mu.Unlock() if abandoned { break } 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) } // 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) { shortTick(t) _, ps, _ := runtimePlugins(t) code := ` local plugin = { name = "healthy", version = "1.0" } plugin.schedule = { { fn = "q", every = 1 } } function plugin.q() return nil 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.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) } }