mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 问题
三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:
ui = { visibilty = "admin" } -- 一个字母之差
⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分
今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。
## 改动
新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。
readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。
## 顺带修掉一个真 bug:handler 必须是名字而非函数值
`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。
## 更正一个我写了但从未核实的说法
之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:
setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
json global present when readPlugin* runs: true
luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages
真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。
一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。
## 判据
- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了
变异自证:strictKeys 短路 → 变红。
已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
245 lines
7.6 KiB
Go
245 lines
7.6 KiB
Go
package lua
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
golua "github.com/aarzilli/golua/lua"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// The declarative decoder.
|
|
//
|
|
// Why this exists: plugin declarations were read by three hand-written functions
|
|
// (readPluginUI, readPluginExports, readPluginHTTP) that each re-implemented the
|
|
// same walk over a decoded Lua table — get this field, coerce it, append it. The
|
|
// field names lived in the .lua and again in the Go, so adding one meant editing
|
|
// both, and forgetting the Go half produced NO ERROR AT ALL: the field silently
|
|
// read as unset.
|
|
//
|
|
// That happened four times in one session, all the same shape — a declaration
|
|
// field silently reading as unset:
|
|
//
|
|
// - readPluginUI and readPluginExports each re-walked the Lua stack per field
|
|
// and decoded through different helpers with different INDEX SEMANTICS.
|
|
// - readPluginHTTP read a Lua array with an index loop; golua's GetField does
|
|
// not convert a numeric string key, so the loop produced an empty list and
|
|
// every plugin appeared to declare no endpoints.
|
|
// - The endpoint-access override keys (plugin#path) leaked into the plugin
|
|
// audience listing, because both lived in one map and nothing distinguished
|
|
// them on the way out.
|
|
//
|
|
// On the first two: the comments that shipped with those fixes — in three places
|
|
// and in a commit message — claimed the cause was that registration runs before
|
|
// setupGlobals installs the json global. That was never verified and is FALSE.
|
|
// Measured: setupGlobals runs at vm.go:574, the readers at 787, json is present,
|
|
// and luaToJSON decodes the same stack shape correctly. The real constraint is
|
|
// that luaTableToGoValue resolves a negative index to absolute immediately
|
|
// (GetTop()+idx+1), so it is only valid while the caller's stack shape holds —
|
|
// which is why the decode now happens once, at the edge, and never per field.
|
|
//
|
|
// // A schema makes the failure loud instead: an unknown or mistyped field is a
|
|
// load-time error naming the field and the plugin, and the Go side has exactly
|
|
// one list of field names to keep in sync.
|
|
//
|
|
// The schema is deliberately data, not reflection or struct tags. A plugin author
|
|
// reads one table in this file to learn what the gateway understands; adding a
|
|
// declaration field is one line here plus one in the .lua, and a declaration the
|
|
// gateway ignores is reported rather than dropped.
|
|
|
|
// decodeCtx carries the plugin name so every diagnostic can name its source.
|
|
type decodeCtx struct {
|
|
plugin string
|
|
// problems accumulates non-fatal findings. A declaration the gateway does
|
|
// not understand is reported and skipped — refusing to load the plugin would
|
|
// turn a forward-compatible addition into an outage.
|
|
problems []string
|
|
}
|
|
|
|
func (c *decodeCtx) warnf(format string, args ...interface{}) {
|
|
c.problems = append(c.problems, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// strictKeys reports keys present in m that the schema does not describe.
|
|
//
|
|
// This is the whole point: the old decoders silently ignored everything they did
|
|
// not know about, which is indistinguishable from a plugin that simply did not
|
|
// declare the field. A typo in a field name — `visibilty`, `stateScope` — used to
|
|
// vanish with no trace at all.
|
|
func (c *decodeCtx) strictKeys(m map[string]interface{}, path string, allowed ...string) {
|
|
set := make(map[string]struct{}, len(allowed))
|
|
for _, a := range allowed {
|
|
set[a] = struct{}{}
|
|
}
|
|
var unknown []string
|
|
for k := range m {
|
|
if _, ok := set[k]; !ok {
|
|
unknown = append(unknown, k)
|
|
}
|
|
}
|
|
if len(unknown) == 0 {
|
|
return
|
|
}
|
|
sort.Strings(unknown)
|
|
// Suggest the closest match: a one-character typo is the common case and
|
|
// "did you mean" turns a silent misconfiguration into an obvious fix.
|
|
for _, u := range unknown {
|
|
if near := closestField(u, allowed); near != "" {
|
|
c.warnf("%s: unknown field %q (did you mean %q?)", path, u, near)
|
|
} else {
|
|
c.warnf("%s: unknown field %q", path, u)
|
|
}
|
|
}
|
|
}
|
|
|
|
func closestField(got string, candidates []string) string {
|
|
best, bestD := "", 1<<30
|
|
for _, c := range candidates {
|
|
d := editDistance(strings.ToLower(got), strings.ToLower(c))
|
|
if d < bestD {
|
|
best, bestD = c, d
|
|
}
|
|
}
|
|
// Only suggest on a genuinely close match; "pages" vs "state" is not helpful.
|
|
if bestD <= 2 {
|
|
return best
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func editDistance(a, b string) int {
|
|
la, lb := len(a), len(b)
|
|
prev := make([]int, lb+1)
|
|
cur := make([]int, lb+1)
|
|
for j := 0; j <= lb; j++ {
|
|
prev[j] = j
|
|
}
|
|
for i := 1; i <= la; i++ {
|
|
cur[0] = i
|
|
for j := 1; j <= lb; j++ {
|
|
cost := 1
|
|
if a[i-1] == b[j-1] {
|
|
cost = 0
|
|
}
|
|
cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost)
|
|
}
|
|
prev, cur = cur, prev
|
|
}
|
|
return prev[lb]
|
|
}
|
|
|
|
func min3(a, b, c int) int {
|
|
if b < a {
|
|
a = b
|
|
}
|
|
if c < a {
|
|
a = c
|
|
}
|
|
return a
|
|
}
|
|
|
|
// ---- field readers -------------------------------------------------------
|
|
//
|
|
// Each reader takes the already-decoded Go value (map[string]interface{} from
|
|
// luaTableToGoValue) rather than the Lua stack. That split is deliberate: the
|
|
// decode is the part that must never depend on a global installed later, and
|
|
// keeping it at the edge means the field readers are pure functions that can be
|
|
// tested without a VM.
|
|
|
|
func decString(m map[string]interface{}, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return asString(m[key])
|
|
}
|
|
|
|
func decStringList(m map[string]interface{}, key string) []string {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
raw, ok := m[key].([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, it := range raw {
|
|
if s := asString(it); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func decTable(m map[string]interface{}, key string) map[string]interface{} {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
t, _ := m[key].(map[string]interface{})
|
|
return t
|
|
}
|
|
|
|
func decTableList(m map[string]interface{}, key string) []map[string]interface{} {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
raw, ok := m[key].([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var out []map[string]interface{}
|
|
for _, it := range raw {
|
|
if t, ok := it.(map[string]interface{}); ok {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// decIntMap reads a string→number table, e.g. state_scope.depth = { global = 1 }.
|
|
func decIntMap(m map[string]interface{}, key string) map[string]int {
|
|
t := decTable(m, key)
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]int, len(t))
|
|
for k, v := range t {
|
|
if n, ok := asFloat(v); ok {
|
|
out[k] = int(n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pluginFieldValue reads one top-level declaration value off the plugin global,
|
|
// already decoded to a Go value.
|
|
//
|
|
// Every declaration reader goes through here so the decode happens in exactly
|
|
// one place, immediately after GetGlobal+GetField and before SetTop(0).
|
|
//
|
|
// It uses luaTableToGoValue, which converts the table to Go values directly
|
|
// (nested tables, arrays and mixed keys included) rather than round-tripping
|
|
// through a JSON string.
|
|
//
|
|
// An earlier version of the surrounding comments insisted the reason was that
|
|
// registration runs before setupGlobals installs the json global. Measured, that
|
|
// is false: setupGlobals is at vm.go:574, the readers at 787, and json IS on the
|
|
// stack. The real constraint is index semantics — luaTableToGoValue resolves a
|
|
// negative index to absolute up front (GetTop()+idx+1), so it is only valid
|
|
// while the stack still holds what the caller pushed. Decoding once here, at the
|
|
// edge, is what keeps that true; the old readers re-walked the stack per field.
|
|
func pluginFieldValue(L *golua.State, field string) (interface{}, bool) {
|
|
L.GetGlobal(pluginGlobal)
|
|
if L.IsNil(-1) {
|
|
L.SetTop(0)
|
|
return nil, false
|
|
}
|
|
L.GetField(-1, field)
|
|
if L.Type(-1) == golua.LUA_TNIL {
|
|
L.SetTop(0)
|
|
return nil, false
|
|
}
|
|
raw := luaTableToGoValue(L, -1)
|
|
L.SetTop(0)
|
|
return raw, raw != nil
|
|
}
|