mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
204 lines
7.8 KiB
Go
204 lines
7.8 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 占满后每次必失败。
|
||
//
|
||
// ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。
|
||
// 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里,
|
||
// 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300
|
||
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
|
||
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
|
||
base := 300
|
||
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
base = n
|
||
}
|
||
}
|
||
ghosts := []int{base, base + 1, base + 2}
|
||
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/.X" + strconv.Itoa(base) + "-lock"
|
||
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
|
||
|
||
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)
|
||
}
|
||
}
|