Files
ModelRouter/internal/lua/plugins_test.go
JianFeeeee d9652f479a fix(plugins): 插件 Lua 报错不再拖垮网关(生产事故修复)
## 事故

13:37 部署后线上 6 次 SIGSEGV 崩溃循环,8081 完全不可用,用户报大量
connect error。崩溃点固定在 internal/lua/plugins.go:invoke → L.Call →
golua StackTrace 里的 lua_getinfo。

## 根因(不是并发/GC/锁)

golua 的 callEx 在**任何** pcall 失败后无条件执行 L.StackTrace(),而
StackTrace 调 lua_getinfo,这个 LuaJIT 构建在栈够深时(带 AUTO 链轨迹的
request_end payload 正好够深)直接段错误。这是 C 层信号,Go 无法 recover,
所以一个插件的脚本错误就能带走整个进程和所有在途请求。

触发错误来自我上一轮加的 billing 日级维度:

    add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), ...)

三个 bucket( 只对应两个 ),最外层 bucket() 只收到一个参数,k=nil,于是
billing.lua:141 `tbl[k] = b` 抛 "table index is nil",**每个请求都抛**。

同时还有第二个 bug:中间层用了 bucket()(返回 emptyBucket,含 cost/requests
字段)当作嵌套容器,结构也是错的。改为 dayMap() 返回纯表。

## 修法

1. billing.lua:修正括号,多层容器改用 dayMap()。
2. **pcall 守卫**(真正的架构修复):在 setupGlobals 里注册
   __llmsproxy_call_hook,钩子改为经它调用。

       function __llmsproxy_call_hook(fn, payload)
         local ok, res = pcall(fn, payload)
         if not ok then return nil, tostring(res) end
         return res, nil
       end

   Lua 侧 pcall 在 golua 看到非零 pcall 状态之前就拦下错误,C 栈回溯路径
   永远进不去。错误变成普通返回值 (nil, msg),Go 侧记进 hook_errors 并跳过
   ——"插件出错不影响请求转发"这条承诺对脚本错误也终于成立,而不只是对 Go panic。

## 这同时修掉了那个查了很久的间歇崩溃

同一个机制解释了此前 8/20 复现、却查不出根因的 SIGSEGV(怀疑过 janitor 竞态、
GC、LuaJIT 全局状态、VM 释放时序,全部排除)。实测对比:

  TestBillingPrecedence   修复前 8/20 崩溃 → 修复后 0/20
  并发建 16 个 VM 的探针   修复前 3/3  崩溃 → 修复后 0/6
  全量 ./...              连跑 5 次全绿

那些崩溃本来就是一个 Lua 钩子错误在栈深时炸掉 StackTrace,时机随机所以看着
像并发问题。

## 判据

TestHookThatRaisesDoesNotCrashTheProcess:装一个每请求必崩的插件,连打 50 次,
断言进程存活 + 错误被记录 + 同状态里健康的 billing 插件照常工作。
3 个变异(守卫不 pcall / 守卫名写错 / 守卫未注册)全部被捕获,其中第一个直接
让 SIGSEGV 重现,说明守卫就是唯一防线。

## 线上验证

往生产插件目录放一个每请求必然报错的插件,连打 30 个真实流式请求:

  30× HTTP 200,SIGSEGV 0 次
  hook_errors 记录 count=44 且指名 zbroken-test(可观测)
  billing 照常累计(2999 请求 / $0.5668)

测试插件已移除。

回滚点:/usr/local/bin/llmsproxy.bak-real-<TS>、billing.lua.bak-real-<TS>。
2026-10-02 14:07:44 +08:00

378 lines
10 KiB
Go

package lua
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// newPluginVM builds a VM plus a plugin registry rooted at dir.
func newPluginVM(t *testing.T) (*VM, *Plugins, string) {
t.Helper()
dir := filepath.Join(t.TempDir(), "adapters")
vm := NewVM(dir)
if err := vm.Start(); err != nil {
t.Fatalf("vm start: %v", err)
}
t.Cleanup(vm.Stop)
pdir := filepath.Join(t.TempDir(), "plugins")
return vm, NewPlugins(vm, pdir), pdir
}
// loadPlugin writes one plugin to disk and loads it.
func loadPlugin(t *testing.T, ps *Plugins, name, code string) error {
t.Helper()
if err := os.MkdirAll(ps.dir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ps.dir, name+".lua"), []byte(code), 0644); err != nil {
t.Fatal(err)
}
return ps.LoadSource(name, code)
}
// TestPluginManifestAndHooks: the two hook registration forms both work and the
// manifest is read.
func TestPluginManifestAndHooks(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = {}
p.name = "demo"
p.version = "1.2.3"
p.description = "a demo plugin"
p.author = "tester"
p.hooks = { request_end = "on_end" }
function p.on_end(payload)
payload.seen = true
payload.name_seen = "demo"
return payload
end
return p
`
if err := loadPlugin(t, ps, "demo", code); err != nil {
t.Fatalf("load: %v", err)
}
list := ps.List()
if len(list) != 1 {
t.Fatalf("List() = %d plugins, want 1", len(list))
}
if list[0]["name"] != "demo" || list[0]["version"] != "1.2.3" {
t.Errorf("manifest not read: %+v", list[0])
}
hooks := list[0]["hooks"].([]string)
if len(hooks) != 1 || hooks[0] != string(StageRequestEnd) {
t.Errorf("hooks = %v, want [request_end]", hooks)
}
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
if out["seen"] != true || out["name_seen"] != "demo" {
t.Errorf("hook did not mutate payload: %+v", out)
}
}
// TestPluginAnonymousHookForm: `request_end = function() end` directly on the
// table must register too, since a single-hook plugin should not need a name.
func TestPluginAnonymousHookForm(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "anon" }
p.request_end = function(payload)
payload.hit = 1
return payload
end
return p
`
if err := loadPlugin(t, ps, "anon", code); err != nil {
t.Fatalf("load: %v", err)
}
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
if out["hit"] != float64(1) {
t.Errorf("anonymous hook did not fire: %+v", out)
}
}
// TestPluginHookStagesFireInOrder: each stage reaches only its own hooks.
func TestPluginHookStagesFireInOrder(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "stages" }
p.hooks = {
request_start = "s1",
routed = "s2",
request_end = "s3",
}
function p.s1(x) x.order = (x.order or "") .. "1" return x end
function p.s2(x) x.order = (x.order or "") .. "2" return x end
function p.s3(x) x.order = (x.order or "") .. "3" return x end
return p
`
if err := loadPlugin(t, ps, "stages", code); err != nil {
t.Fatalf("load: %v", err)
}
payload := map[string]interface{}{}
ps.Fire(StageRequestStart, payload)
ps.Fire(StageRouted, payload)
ps.Fire(StageRequestEnd, payload)
if payload["order"] != "123" {
t.Errorf("stage order = %v, want \"123\"", payload["order"])
}
}
// TestPluginErrorIsContained is the critical safety property: a throwing hook
// must not propagate. Forwarding depends on it.
func TestPluginErrorIsContained(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "boom" }
p.hooks = { request_end = "kaboom" }
function p.kaboom(payload)
error("intentional plugin failure")
end
return p
`
if err := loadPlugin(t, ps, "boom", code); err != nil {
t.Fatalf("load: %v", err)
}
// Must not panic and must return the payload unchanged.
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
if out["model"] != "m" {
t.Errorf("payload was altered by a failing plugin: %+v", out)
}
// And the failure must be visible, not silent.
errs := ps.HookErrors()
if errs["request_end"] == nil {
t.Error("a failing plugin left no error record; it would be silently missing")
}
}
// TestPluginFailingHookDoesNotBlockLaterPlugins: one bad plugin must not stop
// the next one from running.
func TestPluginFailingHookDoesNotBlockLaterPlugins(t *testing.T) {
_, ps, _ := newPluginVM(t)
bad := `
local p = { name = "bad" }
p.hooks = { request_end = "f" }
function p.f(x) error("boom") end
return p
`
good := `
local p = { name = "good" }
p.hooks = { request_end = "f" }
function p.f(x) x.good = true return x end
return p
`
_ = loadPlugin(t, ps, "bad", bad)
if err := loadPlugin(t, ps, "good", good); err != nil {
t.Fatalf("load good: %v", err)
}
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
if out["good"] != true {
t.Errorf("a good plugin was blocked by a failing one: %+v", out)
}
}
// TestPluginSyntaxErrorIsIsolated: a plugin that will not compile is listed
// with its error and is never called — it must not prevent LoadDir from loading
// the rest.
func TestPluginSyntaxErrorIsIsolated(t *testing.T) {
_, ps, _ := newPluginVM(t)
broken := "this is not lua((("
good := `
local p = { name = "ok" }
p.hooks = { request_end = "f" }
function p.f(x) x.ok = true return x end
return p
`
_ = loadPlugin(t, ps, "broken", broken)
if err := loadPlugin(t, ps, "ok", good); err != nil {
t.Fatalf("load ok: %v", err)
}
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
// The broken plugin must not be callable and must carry an error.
for _, row := range ps.List() {
if row["name"] == "broken" {
if row["loaded"] == true {
t.Error("a plugin with a syntax error reported itself as loaded")
}
if row["error"] == nil || row["error"] == "" {
t.Error("a broken plugin carries no error message")
}
}
}
// The good plugin still works.
out := ps.Fire(StageRequestEnd, map[string]interface{}{})
if out["ok"] != true {
t.Errorf("good plugin stopped working: %+v", out)
}
}
// TestPluginUIExtension: a plugin can contribute a page and elements.
func TestPluginUIExtension(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "ui" }
p.hooks = { request_end = "f" }
function p.f(x) return x end
p.ui = {
page = {
page_id = "billing",
title = "Billing",
icon = "💰",
order = 50,
mount = "<div id=billing>hi</div><script>console.log('m')</script>",
},
elements = {
{ target = "status", anchor = "top", mount = "<div>cost</div>" },
},
}
return p
`
if err := loadPlugin(t, ps, "ui", code); err != nil {
t.Fatalf("load: %v", err)
}
// Every contributed page — including the single `page` field — is folded
// into one merged list. Reading ui.Page here would silently pass on a
// payload whose pages were all dropped.
ui := ps.UI()
if len(ui.Pages) != 1 {
t.Fatalf("expected 1 merged page, got %d", len(ui.Pages))
}
pg := ui.Pages[0]
if pg.PageID != "billing" || pg.Title != "Billing" {
t.Errorf("page = %+v", pg)
}
if !strings.Contains(pg.Mount, "console.log") {
t.Error("mount lost its script content")
}
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
t.Errorf("elements = %+v", ui.Elements)
}
}
// TestPluginHookReturnsNilIsNoOpinion: a hook returning nothing must leave the
// payload untouched (plugins should not be forced to echo it back).
func TestPluginHookReturnsNilIsNoOpinion(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "silent" }
p.hooks = { request_end = "f" }
function p.f(payload)
-- records nothing, returns nothing
return nil
end
return p
`
if err := loadPlugin(t, ps, "silent", code); err != nil {
t.Fatalf("load: %v", err)
}
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "ok": true})
if out["model"] != "m" || out["ok"] != true {
t.Errorf("a no-op hook disturbed the payload: %+v", out)
}
}
// TestPluginUIJSONShape is a wire-format guard: the kernel sends this to the
// browser, so the shape is a contract with the WebUI.
func TestPluginUIJSONShape(t *testing.T) {
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "shape" }
p.hooks = { request_end = "f" }
function p.f(x) return x end
p.ui = { elements = { { target = "keys", mount = "<b>k</b>" } } }
return p
`
if err := loadPlugin(t, ps, "shape", code); err != nil {
t.Fatalf("load: %v", err)
}
b, err := json.Marshal(ps.UI())
if err != nil {
t.Fatalf("marshal UI: %v", err)
}
var view struct {
Elements []struct {
Target string `json:"target"`
Mount string `json:"mount"`
} `json:"elements"`
}
if err := json.Unmarshal(b, &view); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(view.Elements) != 1 || view.Elements[0].Target != "keys" {
t.Errorf("UI JSON shape = %+v", view.Elements)
}
}
// TestPluginFireWithNoPluginsIsNoop: an empty registry must not allocate or fail.
func TestPluginFireWithNoPluginsIsNoop(t *testing.T) {
_, ps, _ := newPluginVM(t)
in := map[string]interface{}{"a": 1}
out := ps.Fire(StageRequestEnd, in)
if out["a"] != 1 || ps.Count() != 0 {
t.Errorf("empty registry misbehaved: %+v", out)
}
}
// TestChainStepIsARealStage: chain_step is documented as a distinct stage that
// fires once per step of an AUTO walk. If it were only a field on `routed`, a
// plugin author following the docs would silently get one event instead of the
// whole walk.
func TestChainStepIsARealStage(t *testing.T) {
found := false
for _, s := range AllStages {
if s == StageChainStep {
found = true
}
}
if !found {
t.Fatal("StageChainStep is not in AllStages, so the dispatcher never registers it")
}
// Ordering: it must sit between request_start and routed, which is what
// docs/plugins.md promises.
var iStart, iStep, iRouted = -1, -1, -1
for i, s := range AllStages {
switch s {
case StageRequestStart:
iStart = i
case StageChainStep:
iStep = i
case StageRouted:
iRouted = i
}
}
if !(iStart < iStep && iStep < iRouted) {
t.Errorf("stage order = %v, want request_start < chain_step < routed", AllStages)
}
_, ps, _ := newPluginVM(t)
code := `
local p = { name = "stepper" }
p.hooks = { chain_step = "s" }
function p.s(payload)
payload.kinds = (payload.kinds or "")
return nil
end
return p
`
if err := loadPlugin(t, ps, "stepper", code); err != nil {
t.Fatalf("load: %v", err)
}
// It must actually dispatch.
seen := false
for _, row := range ps.List() {
if row["name"] == "stepper" {
hooks := row["hooks"].([]string)
for _, h := range hooks {
if h == string(StageChainStep) {
seen = true
}
}
}
}
if !seen {
t.Error("a plugin registered for chain_step is not reported as such")
}
}