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/压力/抛弃换代/熔断/健康任务不受影响)。
This commit is contained in:
JianFeeeee
2026-10-11 10:40:58 +08:00
parent 4d1407bbb4
commit 9016eda905
2 changed files with 571 additions and 95 deletions

View File

@ -147,7 +147,13 @@ return plugin
// Transform 只返回 Lua **字符串** 返回值;tick 返回的是 table,故 out 为空
// 不代表任务没跑。真正的观测点是 failures:任务体若抛错会被计入。
if _, err := rt.vm.Transform(rt.poolName, "tick", "{}"); err != nil {
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()
@ -157,7 +163,7 @@ return plugin
t.Errorf("tick 失败 %d 次", fails)
}
// 用 keep 函数读回计数:任务把 n 累加在 state 里,读它证明任务体确实执行过。
out, err := rt.vm.Transform(rt.poolName, "keep", "{}")
out, err := gen.vm.Transform(gen.poolName, "keep", "{}")
if err != nil {
t.Fatalf("keep: %v", err)
}
@ -378,3 +384,181 @@ func luaQuote(s string) string {
}
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)
}
}