mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(zcode): 身份块适配器 + 修复调度器泄漏 + codearts 目录 + 备份裁剪泛化
## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关
参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体
内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}:
无 system → 3012 ✗
仅 cliPrefix(42 字符) → 3012 ✗
cliPrefix + stable(约 2898 字符)→ 200 ✓
⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools
声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode
账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。
实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖
transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移):
block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段
/ block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构)
只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算)
日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构)
文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。
★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像
全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了",
与准入毫不相干、排查完全跑偏。
判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。
另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。
## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器
修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示
**69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。
根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的
调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。
修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload
(调度器必须停在落盘之前,否则它写一个已收尾的 state)。
★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel),
两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。
实测 internal/lua 从 600s 超时 → 62s 通过。
## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点
codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点:
① opengw.../gateway/config → result.models(benefit)
② snap-access.../v1/model/builtin → builtinModels(常规)
而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。
已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。
判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。
## 4. TRAE PKCE 候选来源补齐(3 → 5)
参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的
authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 ——
jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。
判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。
## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现)
prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**;
sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。
实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。
泛化为 prune_backups <prefix> + prune_all_backups(两个前缀都裁),加空前缀守卫。
判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在
裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。
变异 2/2 咬住(只裁 adapters / 去掉守卫)。
## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented)
qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、
zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力,
必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。
清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。
This commit is contained in:
139
internal/lua/plugin_runtime_close_test.go
Normal file
139
internal/lua/plugin_runtime_close_test.go
Normal file
@ -0,0 +1,139 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ★ Close() 必须停掉每个插件的**调度循环**。
|
||||
//
|
||||
// 真实缺陷(与 on_unload 同一形状的第 3 个实例):`Runtime.Stop()` 此前
|
||||
// **只在单插件 Unload 里调**,Close() 不调 —— 于是「进程关闭/测试结束」
|
||||
// 这条路径上,每个插件的 PluginRuntime.loop 永不停止。
|
||||
//
|
||||
// 实测:全量回归在 internal/lua 出现 **69 个 goroutine 卡在 saver/runtime
|
||||
// 循环里**,最终 600s 超时。
|
||||
//
|
||||
// 判据用的是**进程里真实存在的 goroutine 数**:每 LoadSource 一个插件就
|
||||
// 起一个调度循环,Close 必须让它们全部退出 —— 否则 goroutine 只增不减。
|
||||
// 这比"任务被调用过"更贴近缺陷本身(缺陷就是循环不停)。
|
||||
func TestPluginsCloseStopsSchedulerGoroutines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 24 个带调度表的插件:每个起一个 PluginRuntime.loop
|
||||
const n = 24
|
||||
for i := 0; i < n; i++ {
|
||||
src := `
|
||||
local plugin = { name = "s` + string(rune('a'+i)) + `", version = "1.0.0", exports = {} }
|
||||
function plugin.noop_task() end
|
||||
plugin.schedule = { { fn = "noop_task", every = 3600 } }
|
||||
return plugin
|
||||
`
|
||||
name := filepath.Join(pluginDir, "s"+string(rune('a'+i))+".lua")
|
||||
if err := os.WriteFile(name, []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
before := runtime.NumGoroutine()
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
loaded := runtime.NumGoroutine()
|
||||
if loaded <= before {
|
||||
t.Fatalf("加载 %d 个带调度的插件后 goroutine 没涨(%d → %d)—— 夹具失效",
|
||||
n, before, loaded)
|
||||
}
|
||||
ps.Close()
|
||||
|
||||
// ★ Close 之后循环必须**全部**退出,即回落到基线附近。
|
||||
//
|
||||
// ⚠️ 这里不能用「比 loaded 小就算过」:那只要**一个** goroutine 退出就成立
|
||||
// (27 → 26 也满足),而 24 个调度循环可以全部还在跑 —— 我第一版就是
|
||||
// 这么写的,被变异测试当场揭穿(去掉 stopSchedulersAll 后仍然绿)。
|
||||
// 判据必须锚定**基线**,容差只留给无关的运行时 goroutine。
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() <= before+2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
after := runtime.NumGoroutine()
|
||||
if after > before+2 {
|
||||
t.Fatalf("Close() 之后仍有调度循环在跑:goroutine 基线 %d、加载后 %d、Close 后 %d(加载了 %d 个插件)—— "+
|
||||
"它们会一直堆积(实测全量回归堆到 69 个卡死并 600s 超时)", before, loaded, after, n)
|
||||
}
|
||||
t.Logf("✓ Close() 停掉了全部调度循环(goroutine 基线 %d → 加载后 %d → Close 后 %d,%d 个插件)",
|
||||
before, loaded, after, n)
|
||||
}
|
||||
|
||||
// ★ Stop() 必须幂等:Unload 与 Close 都会停调度器,重复调不得 panic。
|
||||
//
|
||||
// 反例:Stop 里裸 `close(rt.stop)` —— 第二次调用 `panic: close of closed channel`。
|
||||
// 关闭路径上的 panic 最糟糕:它掩盖掉操作者本来在做的事(关服务/结束测试)。
|
||||
func TestPluginRuntimeStopIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "sched2", version = "1.0.0", exports = {} }
|
||||
function plugin.noop_task() end
|
||||
plugin.schedule = { { fn = "noop_task", every = 30 } }
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "sched2.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
defer ps.Close()
|
||||
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "sched2" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("插件未拿到 Runtime(夹具失效)")
|
||||
}
|
||||
// 连调三次:第二次若 panic,本测试就会崩掉(正是要防的行为)
|
||||
rt.Stop()
|
||||
rt.Stop()
|
||||
rt.Stop()
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string, d time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("等待 %s 出现超时 —— 调度任务从未运行(夹具失效)", filepath.Base(path))
|
||||
}
|
||||
|
||||
func fileSize(t *testing.T, path string) int64 {
|
||||
t.Helper()
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", path, err)
|
||||
}
|
||||
return st.Size()
|
||||
}
|
||||
Reference in New Issue
Block a user