From cb3ea25586a617aa4854690623ca03ec507f0274 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 13 Oct 2026 07:47:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(lua):=20InvokeHandler=20=E4=BB=8E=E4=B8=8D?= =?UTF-8?q?=20markDirty=20=E2=80=94=E2=80=94=20=E6=97=A0=20hooks=20?= =?UTF-8?q?=E7=9A=84=E6=8F=92=E4=BB=B6=E7=8A=B6=E6=80=81=E6=B0=B8=E4=B8=8D?= =?UTF-8?q?=E8=90=BD=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 channels 插件重启后账号池静默清空:status 返回 accounts={}、pool=null、log=0 条, 磁盘上 `.channels.state.json` 从未存在过(billing/quota 的 .state.json 都在)。 用户侧表现为「登录了,但重启一次账号就没了」。 ## 根因 插件状态持久化有三条分发路径,其中两条会 markDirty: - hook 路径(invoke) → markDirtyLocked ✓ - 导出函数路径(Call) → markDirtyLocked ✓ - HTTP handler 路径(InvokeHandler) → **从不 markDirty** ✗ channels 声明 `hooks = {}`,它的一切状态变更(账号入池、凭据引用、模型黑名单、 事件日志)都只经过自己的 HTTP 路由。于是状态从未被标记为脏, `.channels.state.json` 从未写出,重启即全部丢失。 billing/quota 侥幸没踩到:billing 有 hooks,quota 走 Call。 ## 修复 InvokeHandler 在成功返回前 markDirtyLocked(p),并在 handler 抛错的早退分支 也标记(抛错前可能已改过状态,那次改动仍然是真实的)。与 Call / hook 路径同模式, 只碰 stateSaver 自己的锁,不重入 p.mu(调用方已持锁)。 ## 判据 internal/lua/persist_test.go::TestInvokeHandlerIsPersisted 用「无 hooks、只走 HTTP handler」的插件复现 channels 形态:两次 handler 调用后 Close()(等最后一次 flush),断言 .state.json 存在且含 2 个账号。 变异:撤掉 markDirtyLocked → 测试立即 FAIL(已实测)。 go test ./internal/lua/ 与 ./internal/core/ 全绿。 --- internal/lua/persist_test.go | 64 +++++++++++++++++++++++++++++ internal/lua/plugin_http_handler.go | 16 ++++++++ 2 files changed, 80 insertions(+) diff --git a/internal/lua/persist_test.go b/internal/lua/persist_test.go index 4a08e17..86c21a8 100644 --- a/internal/lua/persist_test.go +++ b/internal/lua/persist_test.go @@ -338,3 +338,67 @@ return plugin t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"]) } } + +// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[], +// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP +// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径 +// 有、Call 路径有,只有它漏了。后果: +// +// .channels.state.json 从未被写出 → 重启后账号池静默清空。 +// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。 +// +// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。 +// 本测试用**无 hooks、只走 handler** 的插件复现该形态。 +func TestInvokeHandlerIsPersisted(t *testing.T) { + ps, _ := newPersistVM(t) + if err := ps.LoadSource("handlersonly", ` +local plugin = { name = "handlersonly", version = "1.0" } +plugin.state = { accounts = {} } +-- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态) +plugin.hooks = {} +plugin.http = { + { path = "add", method = "POST", handler = "add" }, +} +function plugin.add(req) + local body = req.body or "" + local id = body:match('"id":"([^"]+)"') or "unknown" + plugin.state.accounts[id] = { id = id, enabled = true } + return { status = 200, json = { ok = true } } +end +return plugin +`); err != nil { + t.Fatal(err) + } + + for _, id := range []string{"acct-1", "acct-2"} { + resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{ + Method: "POST", Body: `{"id":"` + id + `"}`, + }) + if err != nil { + t.Fatalf("handler: %v", err) + } + if resp == nil { + t.Fatal("handler returned nil response") + } + } + // Close() waits for the final flush, so no timing is involved: anything the + // handlers did NOT mark dirty cannot appear here. + ps.Close() + + b, err := os.ReadFile(ps.stateFile("handlersonly")) + if err != nil { + t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+ + " —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+ + " 否则 channels 的账号池会在每次重启后静默清空", err) + } + var saved struct { + State map[string]interface{} `json:"state"` + } + if err := json.Unmarshal(b, &saved); err != nil { + t.Fatal(err) + } + accts, _ := saved.State["accounts"].(map[string]interface{}) + if len(accts) != 2 { + t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"]) + } +} diff --git a/internal/lua/plugin_http_handler.go b/internal/lua/plugin_http_handler.go index 859a115..f10bf95 100644 --- a/internal/lua/plugin_http_handler.go +++ b/internal/lua/plugin_http_handler.go @@ -264,6 +264,10 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h L.Pop(1) } if errMsg != "" { + // A handler that raised partway may already have mutated plugin.state + // (an account added before a later step failed), so mark it dirty before + // bailing: the mutation it managed to make is still real. + ps.markDirtyLocked(p) return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg) } if L.GetTop() < 1 || L.IsNil(-1) { @@ -273,6 +277,18 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h if err := luaToJSON(L, -1, &raw); err != nil { return nil, err } + // A handler mutates plugin.state just as a hook or an exported Call does, and + // this was the one dispatch path that never marked the plugin dirty. + // + // Consequence (real defect): a plugin with NO hooks — channels is exactly + // that, it declares hooks=[] and drives everything through its own HTTP + // routes — accumulated accounts, credential refs, model blacklists and its + // event log entirely in memory. Nothing ever marked it dirty, so + // .channels.state.json was never written and a restart silently wiped the + // account pool: the operator saw "logged in", restarted for an upgrade, and + // the account was gone. billing/quota escape this only because they declare + // hooks and/or use Call, both of which already mark dirty. + ps.markDirtyLocked(p) return parseHandlerResponse(raw) }