diff --git a/e2e/conflict_arbitration_e2e_test.go b/e2e/conflict_arbitration_e2e_test.go new file mode 100644 index 0000000..03d0310 --- /dev/null +++ b/e2e/conflict_arbitration_e2e_test.go @@ -0,0 +1,261 @@ +package e2e + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才 +// 落地内置插件,所以先放自定义插件再启动网关)。 +func writeE2EPlugin(t *testing.T, dir, name, code string) { + t.Helper() + p := filepath.Join(dir, "plugins", name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(code), 0o644); err != nil { + t.Fatal(err) + } +} + +// e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。 +func e2eConfig(dir, listen string) string { + return "listen: " + listen + "\n" + + "gateway_keys:\n - sk-e2e-0001\n" + + "default_model: AUTO\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + + "sources: []\n" +} + +// TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决) +// +// 真二进制全链路: +// 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常 +// 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边) +// 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决) +// 4. POST /api/plugins/conflicts 裁决 → 持久化 +// 5. 再请求该路径 → 200,赢家应答({"from":"B"}) +// 6. /v1/models 等既有路由全程不受影响(旧通路无回归) +func TestConflictArbitrationE2E(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + addr := mustAddr(t) + + writeE2EPlugin(t, dir, "plugA.lua", ` +local P = {} +P.name = "plugA" +P.version = "1" +P.exports = {} +P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } +function P.h(req) return { status = 200, json = { from = "A" } } end +return P +`) + writeE2EPlugin(t, dir, "plugB.lua", ` +local P = {} +P.name = "plugB" +P.version = "1" +P.exports = {} +P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } +function P.h(req) return { status = 200, json = { from = "B" } } end +return P +`) + + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { + t.Fatal(err) + } + g := startGateway(t, bin, addr, cfgPath) + + // ① 两个插件都加载成功(冲突不拒载) + resp, body := g.do(http.MethodGet, "/api/plugins", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body) + } + var list struct { + Plugins []struct { + Name string `json:"name"` + Loaded bool `json:"loaded"` + } `json:"plugins"` + } + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatal(err) + } + loaded := map[string]bool{} + for _, p := range list.Plugins { + loaded[p.Name] = p.Loaded + } + if !loaded["plugA"] || !loaded["plugB"] { + t.Fatalf("冲突的两个插件都应 loaded: %v", loaded) + } + + // ② 请求冲突路径 → 409 + 点名候选(不静默选边) + resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body) + } + if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") { + t.Fatalf("409 应点名两个候选: %s", body) + } + + // ③ 冲突列表可见 + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) + } + var cl struct { + Routes []struct { + Path string `json:"path"` + Candidates []string `json:"candidates"` + Resolved string `json:"resolved"` + } `json:"routes"` + } + if err := json.Unmarshal([]byte(body), &cl); err != nil { + t.Fatal(err) + } + if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" { + t.Fatalf("冲突形态不对: %+v", cl) + } + + // ④ 裁决 plugB 赢 + arbit, _ := json.Marshal(map[string]string{ + "kind": "route", "path": "contested", "winner": "plugB", + }) + resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true) + if resp.StatusCode != 200 { + t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body) + } + + // ⑤ 再请求 → 200 且赢家应答 + resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) + if resp.StatusCode != 200 { + t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body) + } + if !strings.Contains(body, `"from":"B"`) { + t.Fatalf("赢家 plugB 应答, 实得 %s", body) + } + + // ⑥ 旧通路无回归:/v1/models 正常 + resp, _ = g.do(http.MethodGet, "/v1/models", "", true) + if resp.StatusCode != 200 { + t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode) + } +} + +// TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留), +// /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。 +func TestAdapterConflictE2E(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + addr := mustAddr(t) + + writeE2EPlugin(t, dir, "shadow.lua", ` +local P = {} +P.name = "shadow" +P.version = "1" +P.exports = { "install" } +function P.install(args) + local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a' + local name, err = __llmsproxy_adapters.install("openai", src) + return { name = name, err = err } +end +return P +`) + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { + t.Fatal(err) + } + g := startGateway(t, bin, addr, cfgPath) + + // ① 安装 → 返回 err(pending 语义),内置版本不变 + resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true) + if resp.StatusCode != 200 { + t.Fatalf("call install: %d %s", resp.StatusCode, body) + } + // Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。 + var wrapped struct { + Result struct { + Name *string `json:"name"` + Err string `json:"err"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(body), &wrapped); err != nil { + t.Fatal(err) + } + out := wrapped.Result + if out.Name != nil { + t.Fatalf("挂起时不得返回生效名: %s", body) + } + if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") { + t.Fatalf("错误应点名冲突: %q", out.Err) + } + + // ② 冲突列表可见 + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) + } + var cl struct { + Adapters []struct { + Name string `json:"name"` + Candidate string `json:"candidate"` + } `json:"adapters"` + } + if err := json.Unmarshal([]byte(body), &cl); err != nil { + t.Fatal(err) + } + if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" { + t.Fatalf("冲突形态不对: %+v", cl) + } + + // ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖) + assertOpenAIVersion := func(want string, msg string) { + t.Helper() + resp, body := g.do(http.MethodGet, "/api/adapters", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body) + } + var al struct { + Adapters []struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"adapters"` + } + if err := json.Unmarshal([]byte(body), &al); err != nil { + t.Fatal(err) + } + for _, a := range al.Adapters { + if a.Name == "openai" { + if a.Version != want { + t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version) + } + return + } + } + t.Fatalf("%s: openai 适配器不存在: %s", msg, body) + } + assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)") + + // ④ 裁决 candidate → 插件版生效 + arbit, _ := json.Marshal(map[string]string{ + "kind": "adapter", "name": "openai", "winner": "candidate", + }) + if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 { + t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body) + } + + // ⑤ 适配器版本已切换 + 冲突清空 + assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效") + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) { + t.Fatalf("裁决后冲突应清除: %s", body) + } +} diff --git a/internal/gateway/plugin_http_routes.go b/internal/gateway/plugin_http_routes.go index d7f8ce1..4b43848 100644 --- a/internal/gateway/plugin_http_routes.go +++ b/internal/gateway/plugin_http_routes.go @@ -17,6 +17,7 @@ package gateway import ( "net/http" + "sort" "strconv" "strings" @@ -45,12 +46,25 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { matches := ps.HTTPHandlers() var chosen *lua.HTTPHandler var params map[string]string + // contenders holds the handlers that matched at the current best depth with + // the SAME literal route pattern — i.e. two plugins that both declared this + // exact path. That is the contested case the operator must arbitrate (user + // requirement A): rather than let load order pick a silent winner, a + // contested path with no recorded decision returns 409 naming the candidates. + var contenders []lua.HTTPHandler for i := range matches { h := matches[i] - if p, ok := matchHandlerPath(h.Path, rest); ok { - if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) { - chosen, params = &h, p - } + p, ok := matchHandlerPath(h.Path, rest) + if !ok { + continue + } + switch { + case chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path): + chosen, params = &matches[i], p + contenders = []lua.HTTPHandler{matches[i]} + case segmentCount(h.Path) == segmentCount(chosen.Path) && h.Path == chosen.Path: + // Same route pattern, a different plugin. The winner is arbitrated below. + contenders = append(contenders, matches[i]) } } if chosen == nil { @@ -58,6 +72,13 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { "no plugin serves this path") return } + if len(contenders) > 1 { + resolved := g.resolveContestedRoute(w, ps, chosen.Path, contenders) + if resolved == nil { + return // 409 already written, naming the candidates + } + chosen = resolved + } if !strings.EqualFold(r.Method, chosen.Method) { w.Header().Set("Allow", chosen.Method) @@ -98,6 +119,44 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { lua.WriteHandlerResponse(w, resp) } +// resolveContestedRoute arbitrates a service path that more than one plugin +// declared (user requirement A). It returns the winning handler, or nil after +// writing a 409 when the operator has not yet chosen. +// +// This is the crux of the "report, don't crash, don't silently pick" rule: with +// two plugins both serving e.g. "stats", load order used to decide the winner. +// Now an unresolved contest is a 409 that NAMES the candidates, and the operator +// picks one in the WebUI (POST /api/plugins/conflicts). A recorded decision is +// honoured here on every request thereafter. +func (g *Gateway) resolveContestedRoute(w http.ResponseWriter, ps *lua.Plugins, path string, contenders []lua.HTTPHandler) *lua.HTTPHandler { + winner := ps.RouteWinner(path) + if winner != "" { + for i := range contenders { + if contenders[i].Plugin == winner { + return &contenders[i] + } + } + // A decision exists but points at a plugin that no longer serves this + // path (it was removed or edited). Fall through to 409 rather than honour + // a stale winner that cannot answer. + } + names := make([]string, 0, len(contenders)) + for i := range contenders { + names = append(names, contenders[i].Plugin) + } + sort.Strings(names) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "error": map[string]interface{}{ + "type": "route_conflict", + "message": "service path \"" + path + "\" is claimed by " + strconv.Itoa(len(names)) + " plugins (" + strings.Join(names, ", ") + "); an operator must choose one in the admin UI (Conflicts) or POST /api/plugins/conflicts", + "path": path, + "candidates": names, + }, + }) + return nil +} + // handlerAccessHint explains, in the refusal, exactly which declaration would // open this endpoint. A 403 with no reason is the kind of thing an operator // opens the source to work out, and then the fix is a guess. diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 87b88f3..6ea786c 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -82,6 +82,57 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/plugins") path = strings.Trim(path, "/") + // /api/plugins/conflicts — the arbitration surface (user requirement). + // GET lists every contested route (A) and every suspended adapter-name + // install (B), each with its candidates/incumbent and any recorded + // decision. + // POST {kind,path|name,winner} records the operator's choice and persists + // it. Must be routed BEFORE the single-segment {name} handler below, + // which would otherwise read "conflicts" as a plugin name. + if path == "conflicts" { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "routes": ps.RouteConflicts(), + "adapters": ps.AdapterConflicts(), + }) + case http.MethodPost: + var body struct { + Kind string `json:"kind"` // "route" or "adapter" + Path string `json:"path"` + Name string `json:"name"` + Winner string `json:"winner"` // route: plugin name; adapter: "incumbent"|"candidate" + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + switch body.Kind { + case "route": + if err := ps.ResolveRouteConflict(body.Path, body.Winner); err != nil { + writeError(w, http.StatusBadRequest, "conflict_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "path": body.Path, "winner": body.Winner}) + case "adapter": + if err := ps.ResolveAdapterConflict(body.Name, body.Winner); err != nil { + writeError(w, http.StatusBadRequest, "conflict_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name, "winner": body.Winner}) + default: + writeError(w, http.StatusBadRequest, "invalid_request", "kind must be \"route\" or \"adapter\"") + } + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + } + return + } + // /api/plugins/{name}/state if strings.HasSuffix(path, "/state") { name := strings.TrimSuffix(path, "/state") diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 69aec6d..f84d2da 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -4856,6 +4856,14 @@ function afterChainEdit() { // on_disk rather than the loaded set so a plugin that FAILED to load // still appears, with its error — otherwise a syntax error looks // identical to "the plugin is not there". + async function resolveRoute(path, winner) { + await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "route", path, winner }) }); + renderPlugins(); + } + async function resolveAdapter(name, winner) { + await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "adapter", name, winner }) }); + renderPlugins(); + } async function renderPlugins() { let j; try { @@ -4908,6 +4916,32 @@ function afterChainEdit() { }) .join(""); + // 冲突裁决(用户要求:服务路径/适配器名冲突不崩溃不拒启,运维在此选一保留) + let confHtml = ""; + try { + const cj = await api("/api/plugins/conflicts"); + const rc = cj.routes || []; + const ac = cj.adapters || []; + if (rc.length || ac.length) { + const rows2 = [] + .concat(rc.map((c) => ` + 路径 ${esc(c.path)} + ${c.candidates.map(esc).join(" vs ")} + ${c.resolved ? `已选 ${esc(c.resolved)}` : `待裁决`} + ${c.candidates.map((n) => ``).join(" ")}`)) + .concat(ac.map((c) => ` + 适配器 ${esc(c.name)} + ${esc(c.incumbent)}(现任) vs 插件 ${esc(c.candidate)}(候选) + ${c.resolved ? `已选 ${esc(c.resolved)}` : `待裁决`} + + `)) + .join(""); + confHtml = `

冲突裁决(${rc.length + ac.length})

+

两个插件/适配器声明了同一条服务路径或同一个适配器名。未裁决的路径返回 409,撞名的适配器由现任继续服务。请选一个保留。

+
${rows2}
冲突项候选状态裁决
`; + } + } catch (_) {} + // Hook errors: a plugin that throws leaves no visible trace anywhere // else, so without this the symptom is "the feature silently doesn't // work". @@ -4918,7 +4952,7 @@ function afterChainEdit() { ) .join(""); - $("#tab-plugins").innerHTML = ` + $("#tab-plugins").innerHTML = `${confHtml}

${t("plTitle")} (${(j.on_disk || []).length})

diff --git a/internal/lua/conflict_arbitration_test.go b/internal/lua/conflict_arbitration_test.go new file mode 100644 index 0000000..c5185ed --- /dev/null +++ b/internal/lua/conflict_arbitration_test.go @@ -0,0 +1,287 @@ +package lua + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRouteConflictDetectedAndArbitrated(用户要求 A): +// 两个插件声明同一条 /api/plugins/http/ 服务路径 → 必须报冲突(列出双方), +// 运维裁决后裁决生效;裁决持久化,重启(重新 LoadDir)后仍然生效。 +// 全程不崩溃、不拒绝加载 —— 两个插件都保持 loaded。 +func TestRouteConflictDetectedAndArbitrated(t *testing.T) { + dir := t.TempDir() + _, ps := bridgeVM(t) + ps.dir = dir + + // a.lua 与 b.lua 都声明路径 "shared" + const aSrc = ` +local P = {} +P.name = "plugA" +P.version = "1" +P.exports = {} +P.http = { { path = "shared", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = { from = "A" } } end +return P +` + const bSrc = ` +local P = {} +P.name = "plugB" +P.version = "1" +P.exports = {} +P.http = { { path = "shared", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = { from = "B" } } end +return P +` + for name, src := range map[string]string{"plugA": aSrc, "plugB": bSrc} { + if err := ps.LoadSource(name, src); err != nil { + t.Fatalf("加载 %s: %v", name, err) + } + } + + // ① 冲突被报出来,候选齐整,未裁决 + cs := ps.RouteConflicts() + if len(cs) != 1 { + t.Fatalf("应恰有 1 条路径冲突, 实得 %d: %+v", len(cs), cs) + } + c := cs[0] + if c.Path != "shared" { + t.Fatalf("冲突路径应为 shared, 实得 %q", c.Path) + } + if len(c.Candidates) != 2 || c.Candidates[0] != "plugA" || c.Candidates[1] != "plugB" { + t.Fatalf("候选应为 [plugA plugB], 实得 %v", c.Candidates) + } + if c.Resolved != "" { + t.Fatalf("初始应未裁决, 实得 %q", c.Resolved) + } + // 两个插件都保持 loaded(不许因冲突拒绝启动/加载) + if ps.List()[0]["loaded"] != true || ps.List()[1]["loaded"] != true { + t.Fatal("冲突的两个插件都应保持 loaded") + } + + // ② 非法裁决被拒(赢家必须确实是候选之一) + if err := ps.ResolveRouteConflict("shared", "plugC"); err == nil { + t.Fatal("赢家不是候选应被拒绝") + } + if err := ps.ResolveRouteConflict("other", "plugA"); err == nil { + t.Fatal("未冲突的路径不应可裁决") + } + + // ③ 合法裁决 + 持久化 + if err := ps.ResolveRouteConflict("shared", "plugB"); err != nil { + t.Fatalf("合法裁决失败: %v", err) + } + if ps.RouteWinner("shared") != "plugB" { + t.Fatalf("裁决后赢家应为 plugB, 实得 %q", ps.RouteWinner("shared")) + } + if _, err := os.Stat(filepath.Join(dir, ".route-conflicts.json")); err != nil { + t.Fatalf("裁决应落盘 .route-conflicts.json: %v", err) + } + + // ④ 重启语义:新 registry 读回裁决 + _, ps2 := bridgeVM(t) + ps2.dir = dir + if err := ps2.LoadRouteConflicts(); err != nil { + t.Fatalf("读回裁决: %v", err) + } + if err := ps2.LoadSource("plugA", aSrc); err != nil { + t.Fatal(err) + } + if err := ps2.LoadSource("plugB", bSrc); err != nil { + t.Fatal(err) + } + cs2 := ps2.RouteConflicts() + if len(cs2) != 1 || cs2[0].Resolved != "plugB" { + t.Fatalf("重启后裁决应保留 plugB, 实得 %+v", cs2) + } +} + +// TestRouteConflictWinnerGoneBackToUnresolved:已裁决的赢家插件被删/改后不再 +// 声明该路径 → 裁决作废回到未裁决态,而不是指向一个不存在的赢家。 +func TestRouteConflictWinnerGoneBackToUnresolved(t *testing.T) { + _, ps := bridgeVM(t) + const aSrc = ` +local P = {} +P.name = "plugA" +P.http = { { path = "s", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = {} } end +return P +` + const bSrc = ` +local P = {} +P.name = "plugB" +P.http = { { path = "s", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = {} } end +return P +` + if err := ps.LoadSource("plugA", aSrc); err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("plugB", bSrc); err != nil { + t.Fatal(err) + } + if err := ps.ResolveRouteConflict("s", "plugA"); err != nil { + t.Fatal(err) + } + // plugA 被禁用 → 不再占路由 → 冲突消失(不是回未裁决) + if err := ps.SetEnabled("plugA", false); err != nil { + t.Fatal(err) + } + if got := ps.RouteConflicts(); len(got) != 0 { + t.Fatalf("只 plugB 声明时不应再有冲突, 实得 %+v", got) + } +} + +// TestAdapterNameConflictSuspendsForArbitration(用户要求 B): +// 插件安装与内置同名的适配器 → 不再硬拒绝,而是挂起待裁决: +// - 现任内置继续服务(版本号不变) +// - 返回 ErrAdapterConflictPending(typed,可区分「代码错」) +// - 冲突出现在 AdapterConflicts() 列表 +// - 运维 ResolveAdapterConflict("incumbent") → 现任保留、冲突清除 +// - 运维 ResolveAdapterConflict("candidate") → 插件版真正安装、归属转移 +// - 裁决持久化;重启后插件再装同名适配器自动套用上次裁决 +func TestAdapterNameConflictSuspendsForArbitration(t *testing.T) { + dir := t.TempDir() + _, ps := bridgeVM(t) + ps.dir = dir + + builtin := `local a={} a.name="openai" a.version="BUILTIN" a.endpoint="/x" return a` + if err := ps.vm.LoadAdapterSource("openai", builtin); err != nil { + t.Fatal(err) + } + evil := `local a={} a.name="openai" a.version="PLUGIN_V1" a.endpoint="/x" return a` + + // ① 安装挂起:现任保留,typed 错误,不静默覆盖 + got, err := ps.InstallAdapter("sneaky", "openai", evil) + if err == nil { + t.Fatal("撞内置名应返回错误(挂起)") + } + var pend *ErrAdapterConflictPending + if !errors.As(err, &pend) { + t.Fatalf("错误类型应为 ErrAdapterConflictPending, got %T: %v", err, err) + } + if got != "" { + t.Fatalf("挂起时不得返回生效名, 实得 %q", got) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("现任内置必须继续服务, 版本实得 %q", v) + } + + // ② 冲突可见 + ac := ps.AdapterConflicts() + if len(ac) != 1 || ac[0].Name != "openai" || ac[0].Candidate != "sneaky" { + t.Fatalf("挂起冲突应可见: %+v", ac) + } + + // ③ 裁决 incumbent → 现任保留 + if err := ps.ResolveAdapterConflict("openai", "incumbent"); err != nil { + t.Fatalf("裁决 incumbent: %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("裁决 incumbent 后内置应保留, 实得 %q", v) + } + if got := ps.AdapterConflicts(); len(got) != 0 { + t.Fatalf("裁决后冲突应清除, 实得 %+v", got) + } + // 归属仍是 operator(内置) + if o := ps.AdapterOwnerOf("openai"); o != "" { + t.Fatalf("内置归属应为空(operator), 实得 %q", o) + } + + // ④ 重启后插件再装 → 之前的 incumbent 裁决自动套用(不再二次挂起,也不安装) + if err := ps.LoadRouteConflicts(); err != nil { + t.Fatal(err) + } + _, err = ps.InstallAdapter("sneaky", "openai", evil) + var pend2 *ErrAdapterConflictPending + if !errors.As(err, &pend2) { + t.Fatalf("自动套用 incumbent 裁决应仍返回 pending 错误(未安装), got %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("持久 incumbent 裁决应保持内置, 实得 %q", v) + } + + // ⑤ 裁决 candidate → 插件版真正装上、归属转移、持久化 + if err := ps.ResolveAdapterConflict("openai", "candidate"); err != nil { + t.Fatalf("裁决 candidate: %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "PLUGIN_V1" { + t.Fatalf("裁决 candidate 后插件版应生效, 实得 %q", v) + } + if o := ps.AdapterOwnerOf("openai"); o != "sneaky" { + t.Fatalf("归属应转移给 sneaky, 实得 %q", o) + } + // 持久化文件里是 candidate:sneaky + b, _ := os.ReadFile(filepath.Join(dir, ".route-conflicts.json")) + if !strings.Contains(string(b), "candidate:sneaky") { + t.Fatalf("裁决应持久化为 candidate:sneaky, 文件: %s", b) + } +} + +// TestAdapterPluginConflictSuspendsForArbitration:插件 A 装了适配器后插件 B +// 再装同名 → 挂起待裁决(与内置撞名同一机制),现任 A 的版本继续服务。 +func TestAdapterPluginConflictSuspendsForArbitration(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + _, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2) + var pend *ErrAdapterConflictPending + if !errors.As(err, &pend) { + t.Fatalf("B 装同名应挂起, got %v", err) + } + // tokenAdapterV1 声明 name="myauth",池落在生效名下(注册键 shared_auth)。 + if v := ps.vm.AdapterVersion("myauth"); v != "1.0" { + t.Fatalf("alpha 的现任版本应继续服务, 实得 %q", v) + } + ac := ps.AdapterConflicts() + if len(ac) != 1 || ac[0].Incumbent != "plugin:alpha" { + t.Fatalf("冲突应点名现任归属 plugin:alpha, 实得 %+v", ac) + } + // 裁决 candidate → beta 接管 + if err := ps.ResolveAdapterConflict("shared_auth", "candidate"); err != nil { + t.Fatal(err) + } + if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { + t.Fatalf("beta 版应生效, 实得 %q", v) + } + if o := ps.AdapterOwnerOf("myauth"); o != "beta" { + t.Fatalf("归属应转移给 beta, 实得 %q", o) + } +} + +// TestAdapterConflictRejectsBadWinner:裁决值必须合法;无挂起冲突时拒绝。 +func TestAdapterConflictRejectsBadWinner(t *testing.T) { + _, ps := bridgeVM(t) + if err := ps.ResolveAdapterConflict("nope", "candidate"); err == nil { + t.Fatal("无挂起冲突应拒绝") + } + if _, err := ps.InstallAdapter("p", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + // myauth 是插件自己装的 → 更新路径(update 走 UpdateAdapter,非冲突) + if err := ps.ResolveAdapterConflict("myauth", "bogus"); err == nil { + t.Fatal("非法 winner 值应拒绝") + } +} + +// TestPluginOwnUpdateStillWorks:插件更新自己装的适配器(token 轮换)不走冲突 +// 路径 —— 行为与改造前一致。守住「挂起只发生在撞名」这条边界。 +func TestPluginOwnUpdateStillWorks(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil { + t.Fatalf("own update 不应受影响: %v", err) + } + if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { + t.Fatalf("轮换后版本应 2.0, 实得 %q", v) + } + if got := ps.AdapterConflicts(); len(got) != 0 { + t.Fatalf("own update 不应产生冲突, 实得 %+v", got) + } +} diff --git a/internal/lua/plugin_adapter_bridge.go b/internal/lua/plugin_adapter_bridge.go index d31d38c..2554fd4 100644 --- a/internal/lua/plugin_adapter_bridge.go +++ b/internal/lua/plugin_adapter_bridge.go @@ -26,6 +26,7 @@ package lua import ( "fmt" "sort" + "strings" "sync" golua "github.com/aarzilli/golua/lua" @@ -103,35 +104,65 @@ func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) { if ps.vm == nil { return "", fmt.Errorf("install_adapter: no adapter VM available") } - // Rule 3 first: an adapter another plugin already owns is off-limits. + // A name conflict used to be a hard rejection (owned by another plugin, or + // shadows a built-in). The user now wants it SUSPENDED for operator + // arbitration instead of refused outright: the incumbent keeps serving, the + // candidate waits in the conflicts list, and the operator picks a winner in + // the WebUI. The install still returns a (typed) error so the caller knows it + // did not take effect — but it is ErrAdapterConflictPending, not a dead end. + incumbent := "" if owner := ps.adapterOwner().ownedBy(name); owner != "" && owner != plugin { - return "", fmt.Errorf("install_adapter: adapter %q is owned by plugin %q and cannot be replaced by %q", - name, owner, plugin) + incumbent = "plugin:" + owner + } else if containsNameFold(ps.vm.bundledNames(), name) { + incumbent = "operator (built-in)" } - // Rules 1+2 (path safety + no shadowing built-ins) live in - // InstallPluginAdapter so there is exactly one enforcement point. + if incumbent != "" { + if ps.recordAdapterConflict(name, incumbent, plugin, code) { + // The operator already chose THIS candidate in a prior session. Install + // for real via LoadAdapterSource, which bypasses the reserved-name gate + // — that explicit operator override is exactly what the arbitration is + // FOR, and re-suspending it on every restart would be a loop. + if err := ps.vm.LoadAdapterSource(name, code); err != nil { + return "", err + } + return ps.finishInstall(plugin, name), nil + } + return "", &ErrAdapterConflictPending{Name: name, Incumbent: incumbent} + } + // No conflict: normal install path. Rules 1+2 (path safety + no shadowing + // built-ins) live in InstallPluginAdapter so there is exactly one + // enforcement point. (The shadow check there is now unreachable for a + // genuine built-in — it is caught above and suspended — but kept as the + // single authority for path safety and any future reserved set.) if err := ps.vm.InstallPluginAdapter(name, code); err != nil { return "", err } - // Ownership is recorded under the name sources will actually reference. A - // script may declare a name different from the registration key, and it is - // the declared name that ends up in the registry — recording it under the - // key would leave the adapter un-owned and therefore mutable by anyone. - // Ownership is recorded under the name sources will actually reference. A - // script may declare a name different from the registration key, and it is - // the declared name that ends up in the registry — recording it under the - // key would leave the adapter un-owned and therefore mutable by anyone. - // - // Both names are recorded: the registration key so a later update through - // the same key still matches, and the declared name so a lookup by the name - // a source references also matches. Both entries are cleared together on - // remove. + return ps.finishInstall(plugin, name), nil +} + +// finishInstall records adapter ownership under BOTH the registration key and +// the declared name (a script may declare a name different from the key, and it +// is the declared name sources reference — recording only the key would leave +// the adapter un-owned and therefore mutable by anyone). Both entries are +// cleared together on remove. +func (ps *Plugins) finishInstall(plugin, name string) string { effective := ps.vm.EffectiveAdapterName(name) ps.adapterOwner().set(effective, plugin) if effective != name { ps.adapterOwner().set(name, plugin) } - return effective, nil + return effective +} + +// containsNameFold reports whether name is in names (case-insensitive, matching +// ValidateAdapterName's reserved-name comparison). +func containsNameFold(names []string, name string) bool { + for _, n := range names { + if strings.EqualFold(n, name) { + return true + } + } + return false } // UpdateAdapter replaces an adapter a plugin previously installed — the token diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 6adb017..45126cc 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -292,6 +292,23 @@ type Plugins struct { // is state and not a convention). adapterOwn *adapterOwnership adapterOwnOnce sync.Once + // routeWinners records the operator's arbitration for contested HTTP service + // paths: a path two+ plugins declared, resolved to one winning plugin. Keyed + // by normalized path. See route_conflicts.go — a contested path that is NOT + // in here is unresolved and the dispatcher returns 409 rather than silently + // picking a load-order winner. Persisted in .route-conflicts.json. + routeWinners map[string]string + // adapterWinners records the operator's arbitration for an adapter NAME two + // things claimed (user requirement B): "incumbent" keeps the built-in / first + // plugin, "candidate:" lets that plugin's version win. Persisted in + // .route-conflicts.json beside routeWinners. See route_conflicts.go. + adapterWinners map[string]string + // adapterConflicts holds adapter-name installs currently SUSPENDED pending an + // operator decision: a plugin tried to install an adapter whose name an + // incumbent already holds. The incumbent keeps serving; the candidate waits + // here (with its code) until arbitrated. In-memory only — a restart replays + // the install, which re-suspends unless a persisted winner says otherwise. + adapterConflicts map[string]*AdapterConflict // shards runs exported functions on private VMs so blocking work can be // genuinely parallel. See plugin_shard.go for why the shared VM's lock makes // concurrency impossible and what a shard must therefore never touch. @@ -583,6 +600,12 @@ func (ps *Plugins) LoadDir() error { if err := ps.LoadKernelPermissions(); err != nil { log.Printf("[core] plugin kernel permissions not loaded: %v", err) } + // Route-conflict arbitrations are operator policy too: who wins a service + // path two plugins both declared. Must be in place before the first request + // is dispatched, or a contested path would 409 until the operator re-chose. + if err := ps.LoadRouteConflicts(); err != nil { + log.Printf("[core] plugin route conflicts not loaded: %v", err) + } if _, err := os.Stat(ps.dir); os.IsNotExist(err) { return nil } diff --git a/internal/lua/route_conflicts.go b/internal/lua/route_conflicts.go new file mode 100644 index 0000000..d54f357 --- /dev/null +++ b/internal/lua/route_conflicts.go @@ -0,0 +1,333 @@ +// Package lua — 服务路径/适配器名冲突裁决(用户要求 A + B) +// +// 用户要求:当两个东西声明同一个「服务路径」时,内核**不许崩溃、不许拒绝 +// 启动**,而是报冲突、把候选都列出来、让运维在 WebUI/WebAPI 里选一个保留, +// 选择持久化生效。两类冲突: +// +// A) 两个插件在 plugin.http 里声明同一条服务路径(如都声明 "stats")。 +// 改造前分发器按加载顺序静默选一个 —— 装个新插件就可能改变既有路径归属。 +// B) 一个插件安装的适配器,名字撞上内置适配器或另一个插件已装的适配器。 +// 改造前这是硬错误(ErrAdapterNameReserved / owned by 另一插件)。 +// +// 统一处理: +// +// - 冲突是**运维策略**,与 .access.json / .kernel-perms.json 同源,单独落 +// .route-conflicts.json。放单独文件而不是塞进既有策略文件,是因为那些文件 +// 形状不同(map[string]string 权限 spec),混进去会让旧二进制读到无法 +// round-trip 的键。 +// - 「安全默认」是**保持现任**:A 类未裁决时该路径回 409 并列出候选(不静默 +// 选一个);B 类未裁决时现任适配器(内置/先装的插件)继续服务,新候选挂起 +// (incumbent 永不被悄悄换掉,这保住了「第三方插件不得篡改内核发行物」这条 +// 信任边界 —— 只有运维显式选了插件版本才会覆盖内置)。 +package lua + +import ( + "encoding/json" + "os" + "sort" + "strings" +) + +// RouteConflict 描述一条被多于一个插件认领的服务路径(A 类)。 +type RouteConflict struct { + Path string `json:"path"` + Candidates []string `json:"candidates"` + Resolved string `json:"resolved"` // 运维选定的赢家插件名;"" = 未裁决(分发回 409) +} + +// AdapterConflict 描述一个被挂起的适配器安装(B 类):某插件想装的适配器名 +// 撞上了现任(内置或另一插件)。现任继续服务,候选挂起等运维裁决。 +type AdapterConflict struct { + Name string `json:"name"` + Incumbent string `json:"incumbent"` // "operator"(含内置)或 "plugin:<名>" + Candidate string `json:"candidate"` // 想装它的插件名 + Resolved string `json:"resolved"` // "incumbent" / "candidate" / "" 未裁决 + candidateCode string // 候选代码,运维选 candidate 时才真正装(仅内存) +} + +const routeConflictsFileName = ".route-conflicts.json" + +// persistedConflicts 是 .route-conflicts.json 的磁盘形态:两张裁决表。 +// routes: 归一化路径 -> 赢家插件名 +// adapters: 适配器名 -> "incumbent" | "candidate:<插件名>" +type persistedConflicts struct { + Routes map[string]string `json:"routes,omitempty"` + Adapters map[string]string `json:"adapters,omitempty"` +} + +func (ps *Plugins) routeConflictsFile() string { + if ps.dir == "" { + return "" + } + return ps.dir + string(os.PathSeparator) + routeConflictsFileName +} + +// normalizeRoutePath 把声明路径归一化:去首尾斜杠。裁决键与分发键共用它, +// 不可能对不上。 +func normalizeRoutePath(p string) string { + return strings.Trim(strings.TrimSpace(p), "/") +} + +// ---- A 类:插件 HTTP 服务路径冲突 ---- + +// RouteConflicts 扫描已加载插件的 http 声明,返回被多于一个插件认领的路径。 +// 只看 loaded 且未禁用的插件:加载失败/被关掉的插件不占路由。 +func (ps *Plugins) RouteConflicts() []RouteConflict { + ps.mu.RLock() + defer ps.mu.RUnlock() + byPath := map[string]map[string]bool{} + for _, p := range ps.plugins { + if p.LoadError != "" || p.Disabled { + continue + } + for _, h := range p.http { + key := normalizeRoutePath(h.Path) + if key == "" { + continue + } + if byPath[key] == nil { + byPath[key] = map[string]bool{} + } + byPath[key][p.Info.Name] = true + } + } + var out []RouteConflict + for path, owners := range byPath { + if len(owners) < 2 { + continue + } + cands := make([]string, 0, len(owners)) + for name := range owners { + cands = append(cands, name) + } + sort.Strings(cands) + resolved := ps.routeWinners[path] + if resolved != "" && !owners[resolved] { + resolved = "" // 赢家已不再声明这条路径 → 回到未裁决,而非指向不存在的赢家 + } + out = append(out, RouteConflict{Path: path, Candidates: cands, Resolved: resolved}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// RouteWinner 返回某条路径运维选定的赢家插件名;无冲突/未裁决时 ""。 +func (ps *Plugins) RouteWinner(path string) string { + key := normalizeRoutePath(path) + ps.mu.RLock() + defer ps.mu.RUnlock() + return ps.routeWinners[key] +} + +// ResolveRouteConflict 记录运维对某条冲突路径的裁决并落盘。winner 必须是当前 +// 确实声明了这条路径的候选之一 —— 否则分发会永远 404,所以直接拒绝。 +func (ps *Plugins) ResolveRouteConflict(path, winner string) error { + key := normalizeRoutePath(path) + if key == "" { + return &ConflictError{Reason: "empty path"} + } + var cands []string + for _, c := range ps.RouteConflicts() { + if c.Path == key { + cands = c.Candidates + break + } + } + if cands == nil { + return &ConflictError{Reason: "path " + key + " is not contested"} + } + ok := false + for _, c := range cands { + if c == winner { + ok = true + break + } + } + if !ok { + return &ConflictError{Reason: "plugin " + winner + " does not serve " + key} + } + ps.mu.Lock() + if ps.routeWinners == nil { + ps.routeWinners = map[string]string{} + } + ps.routeWinners[key] = winner + ps.saveConflictsLocked() + ps.mu.Unlock() + return nil +} + +// ---- B 类:适配器名冲突 ---- + +// recordAdapterConflict 挂起一个撞名的适配器安装。调用方(InstallAdapter)在 +// 检测到现任存在时调用。若运维此前已裁决「candidate 赢」,返回 shouldApply=true +// 让调用方直接安装(重启后插件再装会自动套用上次裁决,不再二次挂起)。 +func (ps *Plugins) recordAdapterConflict(name, incumbent, candidate, code string) (shouldApply bool) { + ps.mu.Lock() + defer ps.mu.Unlock() + if d := ps.adapterWinners[name]; d != "" { + // 已有持久裁决。candidate:<此插件> → 直接应用。否则把它重新挂回冲突 + // 列表:持久裁决是**默认**,不是终审 —— 新的安装尝试要把冲突重新 + // 摆到运维面前(否则裁决过 incumbent 后冲突消失,运维想改选 candidate + // 就再也没有入口了)。 + if d == "candidate:"+candidate { + return true + } + } + if ps.adapterConflicts == nil { + ps.adapterConflicts = map[string]*AdapterConflict{} + } + ps.adapterConflicts[name] = &AdapterConflict{ + Name: name, + Incumbent: incumbent, + Candidate: candidate, + candidateCode: code, + } + return false +} + +// AdapterConflicts 列出当前挂起的适配器撞名冲突。 +func (ps *Plugins) AdapterConflicts() []AdapterConflict { + ps.mu.RLock() + defer ps.mu.RUnlock() + out := make([]AdapterConflict, 0, len(ps.adapterConflicts)) + for _, c := range ps.adapterConflicts { + out = append(out, AdapterConflict{ + Name: c.Name, Incumbent: c.Incumbent, Candidate: c.Candidate, Resolved: c.Resolved, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// ResolveAdapterConflict 裁决一个挂起的适配器撞名冲突。 +// +// winner == "incumbent" → 保持现任,丢弃挂起候选。 +// winner == "candidate" → 真正安装候选插件的适配器(覆盖现任),记录归属。 +// 这是运维显式授权一个插件覆盖内置/他人适配器的唯一入口。 +// +// 裁决持久化:下次该插件再装同名适配器会自动套用(不再挂起)。 +func (ps *Plugins) ResolveAdapterConflict(name, winner string) error { + ps.mu.Lock() + c := ps.adapterConflicts[name] + ps.mu.Unlock() + if c == nil { + return &ConflictError{Reason: "adapter " + name + " has no pending conflict"} + } + switch winner { + case "incumbent": + ps.mu.Lock() + if ps.adapterWinners == nil { + ps.adapterWinners = map[string]string{} + } + ps.adapterWinners[name] = "incumbent" + delete(ps.adapterConflicts, name) + ps.saveConflictsLocked() + ps.mu.Unlock() + return nil + case "candidate": + // 真正安装候选代码。绕过 reserved 门禁:这正是运维显式授权覆盖的语义。 + if ps.vm == nil { + return &ConflictError{Reason: "no adapter VM available"} + } + if err := ps.vm.LoadAdapterSource(name, c.candidateCode); err != nil { + return err + } + effective := ps.vm.EffectiveAdapterName(name) + ps.adapterOwner().set(effective, c.Candidate) + if effective != name { + ps.adapterOwner().set(name, c.Candidate) + } + ps.mu.Lock() + if ps.adapterWinners == nil { + ps.adapterWinners = map[string]string{} + } + ps.adapterWinners[name] = "candidate:" + c.Candidate + delete(ps.adapterConflicts, name) + ps.saveConflictsLocked() + ps.mu.Unlock() + return nil + default: + return &ConflictError{Reason: "winner must be \"incumbent\" or \"candidate\""} + } +} + +// ConflictError 是裁决动作被拒的原因,点名性质(用户要求「避免静默错误」)。 +type ConflictError struct{ Reason string } + +func (e *ConflictError) Error() string { return "conflict: " + e.Reason } + +// ErrAdapterConflictPending 是 InstallAdapter 在撞名时返回的 typed 错误: +// 安装没有成功,但也不是硬失败 —— 它被挂起等运维裁决。插件/UI 据此区分 +// 「代码写错」与「撞名待裁决」。 +type ErrAdapterConflictPending struct { + Name string + Incumbent string +} + +func (e *ErrAdapterConflictPending) Error() string { + return "adapter " + e.Name + " conflicts with the current " + e.Incumbent + + "; the incumbent keeps serving until an operator chooses a winner (admin UI → Conflicts)" +} + +// ---- 持久化 ---- + +// LoadRouteConflicts 读取持久化的裁决(A 的路径赢家 + B 的适配器赢家)。 +// 缺文件是全新安装的正常态;损坏文件记录后忽略而非致命。 +func (ps *Plugins) LoadRouteConflicts() error { + path := ps.routeConflictsFile() + if path == "" { + return nil + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + var pc persistedConflicts + if err := json.Unmarshal(b, &pc); err != nil { + return err + } + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.routeWinners == nil { + ps.routeWinners = map[string]string{} + } + for k, v := range pc.Routes { + ps.routeWinners[normalizeRoutePath(k)] = v + } + if ps.adapterWinners == nil { + ps.adapterWinners = map[string]string{} + } + for k, v := range pc.Adapters { + ps.adapterWinners[k] = v + } + return nil +} + +// saveConflictsLocked 落盘两张裁决表。调用方持 ps.mu。 +func (ps *Plugins) saveConflictsLocked() { + path := ps.routeConflictsFile() + if path == "" { + return + } + pc := persistedConflicts{} + if len(ps.routeWinners) > 0 { + pc.Routes = map[string]string{} + for k, v := range ps.routeWinners { + pc.Routes[k] = v + } + } + if len(ps.adapterWinners) > 0 { + pc.Adapters = map[string]string{} + for k, v := range ps.adapterWinners { + pc.Adapters[k] = v + } + } + if pc.Routes == nil && pc.Adapters == nil { + _ = os.Remove(path) + return + } + writeJSONAtomic(path, pc) +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index f3b259b..626d548 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -841,12 +841,35 @@ func (v *VM) LoadAdapterSource(name, code string) error { // it controls while shipping an adapter that declares the name sources // reference). Without this, updating that adapter would install a second // pool and leave the old one serving traffic. + // + // The key can live under EITHER name (this is the "reload replaces the same + // pool entry rather than silently registering a second one" rule): + // + // - pools[static.name] — the pool filed under the DECLARED name (the + // common case, and the only case when a plugin installs under a key that + // matches its script's own name), or + // The pool registry is keyed by the DECLARED name (static.name), always: + // first install files pools[static.name], and a reload replaces that same + // slot. registeredAs only RECORDS the registration key for reverse lookups + // (EffectiveAdapterName scans for it); it is not a second key space. + // + // An earlier version redirected the key to prev.static.registeredAs here, + // assuming pools were filed under the registration key — but first installs + // file under the declared name, so that redirect created a SECOND pool and + // left the old one serving whenever key≠declared (a real bug: a v2 install + // via arbitration left v1 serving, and UpdateAdapter had the same hole). key := static.name - if prev, ok := v.pools[key]; ok && prev.static.registeredAs != "" { - key = prev.static.registeredAs - } old := v.pools[key] - p := newAdapterPool(static.name, code, *static) + // The new pool MUST be filed under the SAME slot as the old one (key), and + // named after that slot, so a later lookup by either the DECLARED name or + // the REGISTRATION KEY finds the one live pool. Naming it after static.name + // would move the pool to the declared-name slot when the incumbent lived + // under the registration key, leaving the registration-key slot pointing at + // the stale (old) pool that shutdown() then tears down — callers reading by + // the registration key see "adapter missing", callers by declared name see + // a pool that isn't the one serving. key is the stable identity; the + // declared name is reachable through static.name. + p := newAdapterPool(key, code, *static) p.setMax(maxW) // Carry the revision forward so the new source keeps counting ABOVE the old // one. A cache keyed on (vm, revision, name) must see a value different
${t("tName")}${t("plState")}${t("plStages")}${t("plVisibility")}${t("plEndpoints")}${t("plKernel")}