From 0f263f895a44bdc2c61c354df3cbcc8f3b58e8a1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 20:12:10 +0800 Subject: [PATCH] =?UTF-8?q?chore(main):=20=E5=9B=9E=E6=B5=81=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=B4=A6=E5=8F=B7=E6=B1=A0=E8=90=BD=E7=9B=98?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E6=80=A7=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按纪律:先在 release/v2.0.x 提交,再内容对齐回 main。 校验:git diff release/v2.0.x = 0 行。 --- internal/core/channels_store_atomic_test.go | 125 ++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 internal/core/channels_store_atomic_test.go diff --git a/internal/core/channels_store_atomic_test.go b/internal/core/channels_store_atomic_test.go new file mode 100644 index 0000000..7ac21e1 --- /dev/null +++ b/internal/core/channels_store_atomic_test.go @@ -0,0 +1,125 @@ +package core + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。 +// +// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的 +// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**, +// 后完成者带着它更早的快照覆盖磁盘。实测形态: +// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**; +// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。 +// +// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前** +// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。 +// 但"不可能"必须被验证,否则只是一句自信。 +func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) { + c, ps := poolCore(t) + // 找出插件的存储目录(内核给的 ground truth,不是猜的) + dirOut, err := ps.Call("channels", "__store_dir_probe", nil) + if err != nil { + t.Fatalf("store_dir_probe: %v", err) + } + dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{}) + dir, _ := dj["store_dir"].(string) + if dir == "" { + t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut) + } + storePath := filepath.Join(dir, ".channels-store.json") + t.Logf("插件存储目录: %s", dir) + // ⚠️ 必须在**测试目录**里,绝不能是生产路径 + if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil { + if filepath.Clean(dir) == "/etc/llmsproxy/plugins" { + t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)") + } + } + + // 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上) + if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{ + "id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"a", "b", "c"} { + if _, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "atomic1", "account_id": id}); err != nil { + t.Fatalf("add %s: %v", id, err) + } + } + // 删 a、删 b(参考的缺陷场景) + for _, id := range []string{"a", "b"} { + if _, err := ps.Call("channels", "account_delete", map[string]interface{}{ + "platform": "atomic1", "account_id": id}); err != nil { + t.Fatalf("delete %s: %v", id, err) + } + } + // 再加一个(参考的第二个缺陷场景:与删除交错的写) + if _, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "atomic1", "account_id": "d"}); err != nil { + t.Fatalf("add d: %v", err) + } + + // 内存视图 + mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"}) + mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{}) + memIDs := map[string]bool{} + for _, a := range asArrInterface(mj["accounts"]) { + if am, ok := a.(map[string]interface{}); ok { + if id, ok := am["id"].(string); ok { + memIDs[id] = true + } + } + } + + // 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件) + b, err := os.ReadFile(storePath) + if err != nil { + t.Fatalf("读存储: %v", err) + } + var disk map[string]interface{} + if err := json.Unmarshal(b, &disk); err != nil { + t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err) + } + // 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。 + st, _ := disk["state"].(map[string]interface{}) + if st == nil { + t.Fatalf("磁盘文档缺 state 段,实得 %v", disk) + } + pool, _ := st["pool"].(map[string]interface{}) + diskIDs := map[string]bool{} + if pl, ok := pool["atomic1"].(map[string]interface{}); ok { + for id := range pl { + diskIDs[id] = true + } + } + + // ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态) + for _, gone := range []string{"a", "b"} { + if diskIDs[gone] { + t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone) + } + if memIDs[gone] { + t.Errorf("已删除的账号 %q 仍在内存视图里", gone) + } + } + // ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态) + if !diskIDs["d"] { + t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)") + } + // ③ 内存与磁盘必须一致(分叉本身就是缺陷) + for id := range memIDs { + if !diskIDs[id] { + t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id) + } + } + for id := range diskIDs { + if !memIDs[id] { + t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id) + } + } + _ = c +}