package core import ( "os" "strconv" "strings" "testing" "modelrouter/internal/lua" ) // 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。 // // 现场(2026-10-13):/tmp/.X-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("MODELROUTER_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) } }