mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
192 lines
7.1 KiB
Go
192 lines
7.1 KiB
Go
package core
|
||
|
||
import (
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。
|
||
//
|
||
// 现场(2026-10-13):/tmp/.X<n>-lock 在 Xvfb 被 SIGKILL 后会留下(插件收尾
|
||
// 就是 `kill -9`)。而旧选号逻辑是「锁/socket 存在就当被占」→ 每个被强杀的
|
||
// 显示器**永久烧掉一个号**。反复跑测试/反复登录后 91 起的整段号被占满
|
||
// (实测 41/41),于是 pickFree 扫不到空位 → `return start`(91) → Xvfb 报
|
||
// “Server is already active for display 91”。
|
||
// 表现为 TestChannelsVncStackEndToEnd 单独 1.07s 通过、全量跑 15s 超时失败。
|
||
//
|
||
// 判据是**行为**,分三步(注意:插件占用期间锁存在是**正确**的 —— Xvfb 自己
|
||
// 会写锁;错的是「用完之后不清理」和「被别人的陈旧锁挡死」):
|
||
// 1. 陈旧锁不得挡住选号(记录 PID 已死)→ 能正常起来,且不报 already active;
|
||
// 2. 空闲号被选中后,锁里应是**真实存活的** PID(即插件真占用了它);
|
||
// 3. 收栈之后,该号的锁与 socket 必须被清掉(这才是泄漏的根治)。
|
||
func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||
const ghost = "999999" // 必然不存在的 PID
|
||
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
|
||
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
|
||
ghosts := []int{91, 92, 93}
|
||
for _, n := range ghosts {
|
||
l := "/tmp/.X" + strconv.Itoa(n) + "-lock"
|
||
if _, err := os.Stat(l); err == nil {
|
||
t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l)
|
||
}
|
||
if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil {
|
||
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
|
||
}
|
||
defer os.Remove(l)
|
||
}
|
||
lock := "/tmp/.X91-lock"
|
||
sock := "/tmp/.X11-unix/X91"
|
||
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。
|
||
injectTestPlatforms(t, ps, `[{
|
||
"id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true,
|
||
"login_url": "http://127.0.0.1:18099/login",
|
||
"base_url": "https://api.xdispfx.example/v1", "adapter": "openai",
|
||
"models": ["x-model-1"]
|
||
}]`)
|
||
|
||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("start_login: %v", err)
|
||
}
|
||
j, _ := out.JSON.(map[string]interface{})
|
||
if j == nil {
|
||
t.Fatalf("无 JSON 响应 (status=%d)", out.Status)
|
||
}
|
||
msg, _ := j["browser_error"].(string)
|
||
if strings.Contains(msg, "already active for display") {
|
||
t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg)
|
||
}
|
||
if msg != "" {
|
||
// 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。
|
||
if _, err := os.Stat(lock); err == nil {
|
||
t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost)
|
||
}
|
||
t.Skipf("浏览器栈未启动(与选号无关): %s", msg)
|
||
}
|
||
|
||
// ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。
|
||
st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
|
||
Method: "GET", Role: "admin",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("browser_status: %v", err)
|
||
}
|
||
sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{})
|
||
used := 0
|
||
if sess != nil {
|
||
if f, ok := sess["display"].(float64); ok {
|
||
used = int(f)
|
||
}
|
||
}
|
||
if used == 0 {
|
||
t.Fatalf("会话未报告 display: %v", st.JSON)
|
||
}
|
||
// 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。
|
||
// 只跳过不复用的实现(变异)会选 94,从而被抓到。
|
||
reclaimed := false
|
||
for _, n := range ghosts {
|
||
if used == n {
|
||
reclaimed = true
|
||
}
|
||
}
|
||
if !reclaimed {
|
||
t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts)
|
||
}
|
||
if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil {
|
||
pid := strings.TrimSpace(string(raw))
|
||
if pid == ghost {
|
||
t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used)
|
||
} else if pid != "" {
|
||
if _, err := os.Stat("/proc/" + pid); err != nil {
|
||
t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid)
|
||
}
|
||
}
|
||
} else {
|
||
t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used)
|
||
}
|
||
|
||
// ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。
|
||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin",
|
||
}); err != nil {
|
||
t.Fatalf("stop_browser: %v", err)
|
||
}
|
||
dl := "/tmp/.X" + strconv.Itoa(used) + "-lock"
|
||
ds := "/tmp/.X11-unix/X" + strconv.Itoa(used)
|
||
if _, err := os.Stat(dl); err == nil {
|
||
t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl)
|
||
_ = os.Remove(dl)
|
||
}
|
||
if _, err := os.Stat(ds); err == nil {
|
||
t.Errorf("收栈后 socket 未清理: %s", ds)
|
||
_ = os.Remove(ds)
|
||
}
|
||
_ = sock
|
||
}
|
||
|
||
// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。
|
||
func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) {
|
||
const disp = 92
|
||
lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock"
|
||
if _, err := os.Stat(lock); err == nil {
|
||
t.Skipf("前置条件不满足:%s 已存在", lock)
|
||
}
|
||
// 用一个**真实存活**的 PID(本进程)写锁。
|
||
if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
||
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
|
||
}
|
||
defer os.Remove(lock)
|
||
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
injectTestPlatforms(t, ps, `[{
|
||
"id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true,
|
||
"login_url": "http://127.0.0.1:18097/login",
|
||
"base_url": "https://api.xdispfx2.example/v1", "adapter": "openai",
|
||
"models": ["x-model-2"]
|
||
}]`)
|
||
defer func() {
|
||
_, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin",
|
||
})
|
||
}()
|
||
|
||
if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||
Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin",
|
||
}); err != nil {
|
||
t.Fatalf("start_login: %v", err)
|
||
}
|
||
|
||
// 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。
|
||
raw, err := os.ReadFile(lock)
|
||
if err != nil {
|
||
t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock)
|
||
}
|
||
if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) {
|
||
t.Errorf("活锁内容被改动: %q(应保持原样)", got)
|
||
}
|
||
}
|