Compare commits

...

1 Commits

Author SHA1 Message Date
cb3ea25586 fix(lua): InvokeHandler 从不 markDirty —— 无 hooks 的插件状态永不落盘
## 现象
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/ 全绿。
2026-10-13 07:48:15 +08:00
2 changed files with 80 additions and 0 deletions

View File

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

View File

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