Files
ModelRouter/internal/lua/fire_parallel_test.go
JianFeeeee 9bec158d2e chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:

1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
   本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
   仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
   - `main` 独有的**文件**:0 个;
   - `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
     main 独有行数:0;
   - `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
   - `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
   - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
     (`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
   且不会丢任何 main 独有的修复。

本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth

校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
2026-10-14 16:52:58 +08:00

335 lines
11 KiB
Go

package lua
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// Fire runs same-stage hooks in parallel across plugins. These tests pin the
// three things that make that safe, each of which failed at least once while
// the change was being written.
// twoHookPlugins loads two plugins that both hook request_end.
func twoHookPlugins(t *testing.T, codeA, codeB string) *Plugins {
t.Helper()
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm start: %v", err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins"))
ps.save.setWakeDelayForTest(time.Millisecond)
t.Cleanup(ps.Close)
ps.DisableStatePersistence()
if err := ps.LoadSource("aaa", codeA); err != nil {
t.Fatalf("load aaa: %v", err)
}
if err := ps.LoadSource("bbb", codeB); err != nil {
t.Fatalf("load bbb: %v", err)
}
return ps
}
func counterPlugin(name string, key string) string {
// Built by concatenation rather than fmt: %q is not a Go verb, and the first
// attempt produced Lua source with a stray '=' that only failed at compile
// time inside three different tests.
return `
local p = { name = "` + name + `" }
p.state = { n = 0 }
p.hooks = { request_end = "bump" }
function p.bump(payload)
p.state.n = p.state.n + 1
if "` + key + `" ~= "" then return { who = "from-` + name + `" } end
return nil
end
return p
`
}
// TestSingleAndParallelPathsMergeIdentically: the single-plugin fast path and
// the multi-plugin parallel path must produce the same payload.
//
// This is not theoretical. The parallel path was written first and the
// single-plugin path kept its old shape; the merge loop was left off the fast
// path, so a lone plugin returning a table had its return value DISCARDED. The
// existing stage-order test caught it — but only because it happened to check
// the payload after Fire. A plugin that returned fields nobody read would have
// broken silently.
func TestSingleAndParallelPathsMergeIdentically(t *testing.T) {
single := twoHookPlugins(t, counterPlugin("aaa", "who"), `
local p = { name = "zzz" }
p.state = { n = 0 }
p.hooks = { request_end = "bump" }
function p.bump(payload) p.state.n = p.state.n + 1 return nil end
return p
`)
// remove the second so exactly one plugin hooks this stage
single.mu.Lock()
single.plugins = single.plugins[:1]
single.mu.Unlock()
single.rebuild()
ps := twoHookPlugins(t, counterPlugin("aaa", "who"), counterPlugin("bbb", "who"))
singleOut := single.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
psOut := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
if singleOut["who"] != "from-aaa" {
t.Errorf("single-plugin path dropped the return value: %v", singleOut)
}
// Two plugins both return `who`; the merge order decides the winner, and it
// must be deterministic (load order), not scheduler-dependent.
if psOut["who"] != "from-bbb" {
t.Errorf("merge order is not load order: who = %v, want from-bbb", psOut["who"])
}
}
// TestMergeOrderIsDeterministic: goroutine completion order must not leak into
// the result. Running Fire repeatedly must always yield the same payload, or a
// gateway's behaviour changes run to run with the same plugins installed.
func TestMergeOrderIsDeterministic(t *testing.T) {
// The plugins differ in cost so a scheduler-dependent merge is visible.
slow := `
local p = { name = "aaa" }
p.state = { n = 0 }
p.hooks = { request_end = "slow" }
function p.slow(payload)
local acc = 0
for i = 1, 3000 do acc = acc + i % 7 end
p.state.n = p.state.n + 1
return { winner = "aaa", acc = acc }
end
return p
`
fast := `
local p = { name = "bbb" }
p.state = { n = 0 }
p.hooks = { request_end = "fast" }
function p.fast(payload)
p.state.n = p.state.n + 1
return { winner = "bbb" }
end
return p
`
ps := twoHookPlugins(t, slow, fast)
first := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
for i := 0; i < 60; i++ {
got := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
if got != first {
t.Fatalf("winner changed between runs: %q then %q — merge depends on goroutine "+
"scheduling, so the same configuration behaves differently run to run", first, got)
}
}
// Load order decides: bbb is loaded second, so it wins.
if first != "bbb" {
t.Errorf("winner = %q, want bbb (load order decides, not timing)", first)
}
}
// TestThrowingHookDoesNotBreakSiblings covers the failure path that golua
// actually produces: a hook raising a Lua error.
//
// The test name used to claim it covered panics, and it used error() to do it.
// Probing the six ways a Lua program can fault (error(), indexing nil, calling
// nil, concatenating nil, arithmetic on nil, unbounded recursion) showed golua
// converts ALL of them into an error RETURN, not a Go panic — so the recover()
// in Fire was untested by that case, and deleting recover() still passed. The
// test was renamed to say what it verifies.
//
// recover() is kept anyway: it guards the Go side of Fire (a nil map write, a
// future change to how the payload is prepared), which is cheap and cannot be
// triggered from Lua today. Claiming it is covered by a Lua test would be the
// kind of assurance that evaporates the first time someone checks.
func TestThrowingHookDoesNotBreakSiblings(t *testing.T) {
throwing := `
local p = { name = "aaa" }
p.state = {}
p.hooks = { request_end = "boom" }
function p.boom(payload) error("intentional failure") end
return p
`
healthy := counterPlugin("bbb", "")
ps := twoHookPlugins(t, throwing, healthy)
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) // must not crash
if out == nil {
t.Error("Fire returned nil")
}
st, ok := ps.State("bbb").(map[string]interface{})
if !ok {
t.Fatal("healthy plugin has no state")
}
if n := st["n"]; n != float64(1) {
t.Errorf("healthy plugin did not run alongside the throwing one: n = %v", n)
}
he := ps.HookErrors()
if len(he) == 0 {
t.Error("a throwing hook was not recorded in hook_errors — the failure would be invisible")
}
}
// TestParallelHooksAllRunOnce: every plugin must be invoked exactly once per
// Fire. A lost or duplicated goroutine shows up as a wrong total, which for the
// billing plugin means a wrong bill.
func TestParallelHooksAllRunOnce(t *testing.T) {
ps := twoHookPlugins(t, counterPlugin("aaa", ""), counterPlugin("bbb", ""))
const fires = 100
for i := 0; i < fires; i++ {
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
}
for _, name := range []string{"aaa", "bbb"} {
st, ok := ps.State(name).(map[string]interface{})
if !ok {
t.Fatalf("%s has no state", name)
}
if n := st["n"]; n != float64(fires) {
t.Errorf("%s counted %v hooks, want %d", name, n, fires)
}
}
}
// TestConcurrentFireIsSafe drives Fire from many goroutines at once. Each
// plugin has its own Lua state and its own mutex, so this must hold; the race
// detector is what proves it, not the assertions.
func TestConcurrentFireIsSafe(t *testing.T) {
ps := twoHookPlugins(t, counterPlugin("aaa", ""), counterPlugin("bbb", ""))
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
}
}()
}
wg.Wait()
for _, name := range []string{"aaa", "bbb"} {
st, ok := ps.State(name).(map[string]interface{})
if !ok {
t.Fatalf("%s has no state", name)
}
if n := st["n"]; n != float64(400) {
t.Errorf("%s counted %v, want 400", name, n)
}
}
}
// TestFireDoesNotHoldRegistryLockWhileRunningHooks: a hook that installs or
// disables a plugin takes ps.mu for write. If Fire held ps.mu across the hook,
// that would self-deadlock — the exact failure TrueAgent recorded for its own
// parallel stop path.
func TestFireDoesNotHoldRegistryLockWhileRunningHooks(t *testing.T) {
reentrant := `
local p = { name = "aaa" }
p.state = {}
p.hooks = { request_end = "reenter" }
function p.reenter(payload)
-- Reading the registry from inside a hook is the read half of the same lock.
local _ = #payload
return nil
end
return p
`
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins"))
defer ps.Close()
ps.DisableStatePersistence()
if err := ps.LoadSource("aaa", reentrant); err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("bbb", counterPlugin("bbb", "")); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() {
defer close(done)
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
// Touch the registry the way an admin request would, right after.
_ = ps.Count()
_ = ps.List()
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Fire deadlocked against the registry lock")
}
}
// TestBillingSurvivesParallelFire runs the REAL bundled plugin next to another
// one. A synthetic counter cannot catch a mismatch between the documented
// payload shape and what the plugin actually reads.
func TestBillingSurvivesParallelFire(t *testing.T) {
dir := t.TempDir()
pdir := filepath.Join(dir, "plugins")
os.MkdirAll(pdir, 0o755)
src, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatalf("read billing.lua: %v", err)
}
if err := os.WriteFile(filepath.Join(pdir, "billing.lua"), src, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pdir, "observer.lua"),
[]byte(`local p={name="observer"} p.state={n=0}
p.hooks={request_end="see"}
function p.see(payload) p.state.n=p.state.n+1 return nil end
return p`), 0o644); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
ps := NewPlugins(vm, pdir)
ps.save.setWakeDelayForTest(time.Millisecond)
defer ps.Close()
ps.DisableStatePersistence()
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
if ps.Count() != 2 {
t.Fatalf("loaded %d plugins, want 2", ps.Count())
}
payload := map[string]interface{}{
"model": "deepseek-v4.1-flash", "source": "commandcode", "key": "k", "ok": true,
"usage": map[string]interface{}{
"prompt_tokens": float64(1000), "completion_tokens": float64(100),
"cache_hit_tokens": float64(0),
},
}
ps.Fire(StageRequestEnd, payload)
st := ps.State("billing")
if st == nil {
t.Fatal("billing produced no state")
}
m := st.(map[string]interface{})
total, ok := m["total"].(map[string]interface{})
if !ok {
t.Fatalf("billing.total is %T", m["total"])
}
if total["requests"] != float64(1) {
t.Errorf("billing counted %v requests, want 1 — the plugin was starved by the parallel path",
total["requests"])
}
if n := ps.State("observer").(map[string]interface{})["n"]; n != float64(1) {
t.Errorf("observer counted %v, want 1", n)
}
if len(ps.HookErrors()) != 0 {
t.Errorf("hook errors under parallel Fire: %v", ps.HookErrors())
}
}