Files
ModelRouter/internal/lua/plugin_adapter_bridge_lua_test.go
JianFeeeee dafc3b3fc3 feat(lua): 插件 Lua 侧适配器桥接(R4 Lua 绑定)+ 真二进制适配器接手 e2e
补上两个此前报告为"缺口"的东西。其中第一个的旧结论是**错的**,这里更正。

## R4 Lua 绑定:__llmsproxy_adapters

用户问的"为什么 Lua 绑不上"—— 结论是**能绑,之前的归因错了**。

旧结论是「NewTable + PushGoClosure + SetField 是 golua 雷区,会 SIGSEGV」。
反证:内核自己的 buildJSONTable 用的就是这同一个模式,长期稳定。真正的变量
是当时 Phase 4 的**双 VM 半成品**(钩子一个 VM、定时任务一个私有 VM),
bootPluginState 签名与调用点不一致,我改到一半编译不过就仓促回滚,留下的
中间态让 billing 一跑就崩。把双 VM 改回单 VM 之后,同一模式一次就通。

顺带回答一个备选方案(用户提出:Lua 只投递请求、Go 串行执行):不需要。
p.mu 已经天然串行化同一插件的所有入口;再加队列只会引入"谁排空/超时后积压
怎么算"的新问题。而且桥接调用本身是微秒级的(交给 Go 重建一个小池),真正
长耗时的是插件自己去取 token 的 HTTP 请求,那跟桥接无关。

能力(Go 侧强制,非约定):
- install(name, source) → (生效名, nil) | (nil, err)
- update(name, source) → (true, nil) | (nil, err)  只限自己装的
- remove(name)         → (true, nil) | (nil, err)  只限自己装的
- 调用身份是插件**启动时的固定名**,不从 Lua 状态现读 ⇒ 无法冒充别的插件
- 不能影子内置(Lua 侧同样被拒,不是绕过 Go 校验的后门)
- 出错返回 (nil, err) 而非抛异常,与钩子/HTTP 处理器同一套约定

**分片 VM 故意不给**:分片是文档化的「只读、可丢弃」工作单元,而适配器
install 是**全局**副作用(改共享注册表)。给分片等于让"只读"的按需调用悄悄
改写所有请求共用的适配器 —— 正是分片契约要防的不可见变更。

测试 6 个:install/rotate/remove 从 Lua 调用、影子内置被拒、**定时任务里
轮换 token 且请求钩子立即可见**(R4 闭环)、分片无桥接。

## 真二进制 e2e:适配器接手对外服务

用户问到的第二个点。此前 /v1/messages 的 Anthropic 接管只在**进程内**
gateway 层测过(internal/gateway/messages_test.go),绕过了只会在真实进程里
发生的事:适配器磁盘 seed、配置加载、真 HTTP 栈。

新增 e2e/adapter_takeover_test.go,打**真编译的二进制** + 只说 Anthropic 的
假上游(该上游对不符形状的请求主动报错,不做宽容接受):
- 非流式:出向必须是 Anthropic message 形状,不能有 OpenAI choices 外壳
- 流式:必须是 message_start…message_stop 序列,**不得**出现 [DONE]
- 压力:96 并发(48 流式)混合打真二进制,全部 200,无 5xx,无形状串味

压力测试初版把并发上限设成 4 于是大量 429,我把断言改正为:429 是内核**正确**
的容量背压,不算失败;失败的定义是 5xx 或 200 但内容/形状错。现在 96/96 通过。
2026-10-11 13:25:11 +08:00

235 lines
8.5 KiB
Go
Raw Permalink 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 (
"testing"
"time"
)
// 本文件验证 R4 的 Lua 侧绑定:**插件自己的 Lua 代码**能在运行时安装/改写/
// 删除自己的适配器。
//
// 为什么需要单独一层测试:Go 侧的 InstallAdapter/UpdateAdapter 在
// plugin_adapter_bridge_test.go 里已直接验证过,但那证明的是"Go API 能工作"。
// token 轮换的真实形态是插件在定时任务或钩子里**从 Lua 发起**这个调用,所以
// 绑定本身(__llmsproxy_adapters 表)必须被证明可用 —— 而且它曾经在 cgo 层
// SIGSEGV,所以"不崩"本身就是一条断言。
const bridgeProbe = `
local plugin = { name = "authtoken", version = "1.0" }
plugin.exports = { "install_via_lua", "update_via_lua", "remove_via_lua", "install_operator_owned" }
local function src(ver, tok)
return 'return { name = "myauth", version = "' .. ver .. '", endpoint = "/x", token = "' .. tok .. '" }'
end
function plugin.install_via_lua(args)
local name, err = __llmsproxy_adapters.install("myauth", src("1.0", args.tok))
return { name = name, err = err }
end
function plugin.update_via_lua(args)
local ok, err = __llmsproxy_adapters.update("myauth", src("2.0", args.tok))
return { ok = ok, err = err }
end
function plugin.remove_via_lua()
local ok, err = __llmsproxy_adapters.remove("myauth")
return { ok = ok, err = err }
end
-- 试图替换一个非自己拥有的适配器:必须被拒(不能影子内置)
function plugin.install_operator_owned()
local name, err = __llmsproxy_adapters.install("openai", src("9.9", "evil"))
return { name = name, err = err }
end
return plugin
`
// TestPluginInstallsAdapterFromLua 是 R4 绑定的主断言。
func TestPluginInstallsAdapterFromLua(t *testing.T) {
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("authtoken", bridgeProbe); err != nil {
t.Fatal(err)
}
out, err := ps.Call("authtoken", "install_via_lua", map[string]interface{}{"tok": "T1"})
if err != nil {
t.Fatalf("Lua 侧 install 调用失败: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("返回值不是 table: %#v", out)
}
if m["name"] != "myauth" {
t.Fatalf("install 应返回生效名 myauth, 实得 %#v (err=%#v)", m["name"], m["err"])
}
// 归属确实被记录(这条规则是"插件只能改自己装的"的基础)
if owner := ps.AdapterOwnerOf("myauth"); owner != "authtoken" {
t.Errorf("归属应为 authtoken, 实得 %q", owner)
}
}
// TestPluginRotatesTokenViaLua 是 token 轮换的完整形态:
// 同一个插件先装、再改,改后新版本生效。
func TestPluginRotatesTokenViaLua(t *testing.T) {
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("authtoken", bridgeProbe); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("authtoken", "install_via_lua", map[string]interface{}{"tok": "OLD"}); err != nil {
t.Fatalf("install: %v", err)
}
// 轮换
out, err := ps.Call("authtoken", "update_via_lua", map[string]interface{}{"tok": "NEW"})
if err != nil {
t.Fatalf("update: %v", err)
}
m := out.(map[string]interface{})
if m["ok"] != true {
t.Fatalf("Lua 侧 update 未成功: %#v", m)
}
// 新代码必须真的生效 —— 这是"轮换有意义"的定义
if rev := ps.vm.AdapterRevision("myauth"); rev < 2 {
t.Errorf("适配器修订号应 >=2(已被替换一次),实得 %d", rev)
}
}
// TestPluginRemoveAdapterFromLua:插件能删掉自己装的。
func TestPluginRemoveAdapterFromLua(t *testing.T) {
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("authtoken", bridgeProbe); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("authtoken", "install_via_lua", map[string]interface{}{"tok": "T"}); err != nil {
t.Fatal(err)
}
out, err := ps.Call("authtoken", "remove_via_lua", map[string]interface{}{})
if err != nil {
t.Fatalf("remove: %v", err)
}
if out.(map[string]interface{})["ok"] != true {
t.Fatalf("Lua 侧 remove 未成功: %#v", out)
}
if owner := ps.AdapterOwnerOf("myauth"); owner != "" {
t.Errorf("删除后归属应清空, 实得 %q", owner)
}
}
// TestLuaBridgeCannotShadowBuiltin:从 Lua 侧同样不能影子内置适配器 ——
// 否则绑定就成了绕过 Go 侧校验的后门。
func TestLuaBridgeCannotShadowBuiltin(t *testing.T) {
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("authtoken", bridgeProbe); err != nil {
t.Fatal(err)
}
out, err := ps.Call("authtoken", "install_operator_owned", map[string]interface{}{})
if err != nil {
t.Fatalf("调用本身不应报错(错误应作为返回值): %v", err)
}
m := out.(map[string]interface{})
if m["name"] != nil {
t.Fatalf("影子内置 openai 必须被拒,却返回了 name=%#v", m["name"])
}
if m["err"] == nil || m["err"] == "" {
t.Fatal("影子内置应带回错误信息")
}
t.Logf("拒绝信息: %v", m["err"])
}
// TestScheduledTaskRotatesTokenEndToEnd 是 R4 的闭环验收。
//
// 它把三个特性串成一条链,任何一个环节坏掉都会让它失败:
// 1. 内核 ticker 驱动定时任务(无外部触发)
// 2. 任务与钩子跑在同一个 VM(所以任务的 install 与 state 写入对钩子可见)
// 3. Lua 侧适配器桥接可用(__llmsproxy_adapters.install)
//
// 这正是用户描述的 token 轮换形态:插件自己按时间拿到新 token,重写适配器,
// 后续请求自然使用新逻辑 —— 全程没有任何浏览器或人工操作。
func TestScheduledTaskRotatesTokenEndToEnd(t *testing.T) {
shortTick(t)
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "sched_rot", version = "1.0" }
plugin.state = { token = "OLD", rotates = 0 }
plugin.schedule = { { fn = "rotate", every = 1 } }
function plugin.rotate()
plugin.state.rotates = plugin.state.rotates + 1
local tok = "T" .. tostring(plugin.state.rotates)
local src = 'return { name = "rotauth", version = "1.0", endpoint = "/x", token = "' .. tok .. '" }'
plugin.state.last_adapter, plugin.state.rotate_err = __llmsproxy_adapters.install("rotauth", src)
plugin.state.token = tok
end
plugin.hooks = { routed = "observe" }
function plugin.observe(payload)
payload.token = plugin.state.token
payload.rotates = plugin.state.rotates
payload.adapter = plugin.state.last_adapter
return payload
end
return plugin
`
if err := ps.LoadSource("sched_rot", code); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
out := ps.Fire(StageRouted, map[string]interface{}{})
if n, ok := out["rotates"].(float64); ok && n >= 1 {
t.Logf("任务轮换了 %v 次; 钩子看到 token=%v adapter=%v", n, out["token"], out["adapter"])
if out["adapter"] != "rotauth" {
t.Fatalf("任务里 install 未生效: adapter=%v", out["adapter"])
}
return
}
time.Sleep(30 * time.Millisecond)
}
t.Fatal("定时任务未完成 token 轮换")
}
// TestShardVMHasNoAdapterBridge 锁定一条边界:分片 VM(ShardCall 用的私有 VM)
// **不能**拿到适配器管理桥接。
//
// 理由:分片是文档化的「只读、可丢弃」工作单元,而 install/update/remove 是
// **全局**副作用(改的是共享的适配器注册表,不是本 VM 的状态)。若把桥接给
// 分片,一个被声明为只读的按需调用就能悄悄改写所有请求共用的适配器 ——
// 正是分片契约要防的那类不可见变更。
//
// 断言方式是:分片里的插件代码检查 __llmsproxy_adapters 是否为 nil,返回结果。
func TestShardVMHasNoAdapterBridge(t *testing.T) {
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "sharded", version = "1.0" }
plugin.exports = { "probe" }
-- 只读探测:分片里这个全局必须是 nil
function plugin.probe(args)
return { has_bridge = __llmsproxy_adapters ~= nil }
end
return plugin
`
if err := ps.LoadSource("sharded", code); err != nil {
t.Fatal(err)
}
// 主 VM 上应当有桥接(对照)
mainOut, err := ps.Call("sharded", "probe", map[string]interface{}{})
if err != nil {
t.Fatalf("主 VM 调用失败: %v", err)
}
if m, ok := mainOut.(map[string]interface{}); !ok || m["has_bridge"] != true {
t.Errorf("主 VM 上应有 __llmsproxy_adapters, 实得 %#v", mainOut)
}
// 分片 VM 上不应有
outs, err := ps.ShardCall("sharded", "probe", []map[string]interface{}{{}, {}})
if err != nil {
t.Fatalf("ShardCall: %v", err)
}
for i, o := range outs {
m, ok := o.(map[string]interface{})
if !ok {
t.Fatalf("分片 %d 返回非 table: %#v", i, o)
}
if m["has_bridge"] == true {
t.Errorf("分片 %d 不应拿到适配器桥接(分片是只读可丢弃的)", i)
}
}
}