Files
ModelRouter/internal/lua/plugin_runtime_test.go
JianFeeeee 9016eda905 feat(lua): 卡死任务改为「抛弃整个 VM」—— 不中断,直接换一代
用户给的架构决策:与其想尽办法中断卡死的插件任务,不如直接抛弃它所在的
VM,让任务的 goroutine 跟 VM 一起被处置。这比中断干净 —— VM 重建只要
~0.1ms,抛弃比修复便宜。

## 为什么必须这样做(三次实测)

本 golua + LuaJIT 构建**无法中断**运行中的 Lua 调用:
  1. SetExecutionLimit(200000) 对紧密循环      10s 未返回 —— 无效
  2. 计数 hook(2000) + RaiseError 对紧密循环   fired=0   —— hook 未触发
  3. 计数 hook(1000) 对「函数调用+表写」循环   fired=20971 触发,
     但 RaiseError 后仍未返回 —— RaiseError 走 L.StackTrace(),
     正是 vm.go 已记录的 SIGSEGV / 无法跨 C 栈路径

原因:LuaJIT 把紧密循环编译成机器码,指令计数 hook 只在解释器路径触发;
而唯一能在 C 栈上中断的 golua 入口恰好依赖 StackTrace()。

## 模型:generation 可抛弃

PluginRuntime 持有一个 runtimeGen(私有 VM + 单状态池)。任务超过
wedgedAfter(10min,因为正常情况就是慢网络调用)仍不返回时:

- 抛弃该 generation,由 ensureGeneration 在下一 tick 起一个干净的
- 卡死的 goroutine 留在旧 VM 上,永远碰不到新一代的状态
- 在飞任务被 carry 到新 generation 并重新排期(否则 abandon 计数永不前进,
  熔断也就永远不触发)

## 熔断(必须有)

每次抛弃都留下一个永不返回的 goroutine + Lua 状态。没有上限的话,一个
必然卡死的任务会让运行时每 10 分钟建一个新 VM —— 由插件自身 bug 驱动的
无界泄漏。maxAbandons=5 后熔断停止重生,需人工 reload(同 systemd 单元
反复启动失败后的姿态)。

## 开发中修掉的四个缺陷

1. rt.gen 未赋值:bootGeneration 的结果只赋给局部变量,导致 checkWedged/
   runDue/dropGeneration 全部读到 nil,运行时会「活着但一个任务都不跑」。
2. checkWedged 无条件抛弃:即使没有任何任务判定为 wedged 也 drop,
   让健康插件不断换代。
3. startedAt 由 runDue 设置而非 runOne:任何其它 dispatch 路径的任务
   都不会被追踪,checkWedged 无法区分「在跑」与「从未启动」。
4. runOne 收到 nil gen 会 panic(抛弃与派发竞争)—— 改为安全返回并清标志。

测试:13 个(无触发自动执行/真正执行/零开销/at 解析/条目校验/失败隔离/
卸载停 goroutine/压力/抛弃换代/熔断/健康任务不受影响)。
2026-10-11 10:40:58 +08:00

565 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}