feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)

此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
   选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。

用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。

## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
  返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
  loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。

## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
  入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
  reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。

## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
  重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。

## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
  赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
  自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
  点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
  保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。

修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
This commit is contained in:
JianFeeeee
2026-10-12 12:32:10 +08:00
parent 6b6f9526ff
commit 5948ad19ca
9 changed files with 1130 additions and 28 deletions

View File

@ -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)
}
}

View File

@ -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.

View File

@ -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")

View File

@ -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) => `
<tr><td><span class="tag tag-red">路径</span> <code>${esc(c.path)}</code></td>
<td>${c.candidates.map(esc).join(" vs ")}</td>
<td>${c.resolved ? `<span class="tag tag-green">已选 ${esc(c.resolved)}</span>` : `<span class="tag tag-red">待裁决</span>`}</td>
<td>${c.candidates.map((n) => `<button class="small" onclick="resolveRoute('${escAttr(c.path)}','${escAttr(n)}')">用 ${esc(n)}</button>`).join(" ")}</td></tr>`))
.concat(ac.map((c) => `
<tr><td><span class="tag">适配器</span> <code>${esc(c.name)}</code></td>
<td>${esc(c.incumbent)}(现任) vs 插件 ${esc(c.candidate)}(候选)</td>
<td>${c.resolved ? `<span class="tag tag-green">已选 ${esc(c.resolved)}</span>` : `<span class="tag tag-red">待裁决</span>`}</td>
<td><button class="small" onclick="resolveAdapter('${escAttr(c.name)}','incumbent')">保留现任</button>
<button class="small" onclick="resolveAdapter('${escAttr(c.name)}','candidate')">用插件版</button></td></tr>`))
.join("");
confHtml = `<div class="card" style="border-color:var(--danger)"><h2>冲突裁决(${rc.length + ac.length})</h2>
<p class="muted">两个插件/适配器声明了同一条服务路径或同一个适配器名。未裁决的路径返回 409,撞名的适配器由现任继续服务。请选一个保留。</p>
<div class="tbl-wrap"><table><tr><th>冲突项</th><th>候选</th><th>状态</th><th>裁决</th></tr>${rows2}</table></div></div>`;
}
} 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}
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
<div class="tbl-wrap"><table><tr>
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th>${t("plKernel")}</th><th></th>

View File

@ -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)
}
}

View File

@ -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

View File

@ -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:<plugin>" 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
}

View File

@ -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)
}

View File

@ -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