mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户原话:「全部放开,插件不让用这些还叫插件吗」。方向是对的:插件架构的边界 应当是**内核暴露给插件的 API 权限**,而不是一层把插件自己的浏览器栈也关在门外的 进程级 syscall 过滤器。 ## ★ 白屏的根因(本次花了大量时间,记录完整线索链) 同一页面、同一 chromium 参数,只换 seccomp: | SystemCallFilter | 结果 | |---|---| | `@system-service` | 页面**永远空白**(截图非白像素 7.9%,连 `typeof WebAssembly` 都不执行) | | 放开 | 完整渲染 TRAE 登录页(非白像素 **90.6%**,标题 `登录 \| TRAE - The Real AI Engineer`) | TRAE 授权页是 React SPA + 字节风控 SDK(`sdk-glue.js`),依赖 WASM/JIT。 白名单**只减不增**地挡住了它。 **为什么不再逐个补白名单**:那是没有终点的维护 —— 每接一个新平台、每换一次前端 SDK 都可能再撞一个缺口,而症状永远是"页面白屏"这种零线索形态。本次为此**错误 怀疑过** 5 个方向(profile 锁 / /tmp 满 / PrivateTmp / 可写 HOME / AF_NETLINK), 全部被排除后才靠**逐 directive 二分**定位。补白名单只会让下一个人重复这个过程。 `packaging/llmsproxy.service` 因此去掉两项: - `SystemCallFilter=@system-service` + `SystemCallArchitectures=native` - `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`(缺 `AF_NETLINK` 会让 chromium 建套接字失败 → SSL `net_error -101` → 导航不了) **保留**的加固(已逐条实测不影响浏览器栈):`NoNewPrivileges` / `ProtectSystem=strict` / `ReadWritePaths=/etc/llmsproxy` / `ProtectHome` / `PrivateTmp` / `CapabilityBoundingSet=`(空)/ `ProtectKernel*` / `RestrictSUIDSGID` / `RestrictRealtime` / `LockPersonality`。可写面仍收窄到 `/etc/llmsproxy`。 ## 修掉的 3 个插件缺陷(都是真 bug,各自有判据) 1. **前向引用(第 12 次)**:`sweepSessions` 调 `reapLoopback`/`browserStack`/ `reapBrowser`,三者定义在**后面** ⇒ 未声明时是全局 nil。加载期不报错,只有走到 「会话过期回收」才炸 ⇒ **第一次登录正常、第二次 500**,且已起的浏览器栈不被回收 (进程与端口泄漏)。→ 加前向声明 + 定义改赋值式。 2. **`spawnBrowser` 里 `url` 是未定义全局**(形参名是 `start_url`)⇒ chromium 打开 `http://nil/`。修复前页面 URL = `http://nil/`,修复后 = 完整 `trae.cn/authorization?...`。 3. **chromium 在加固环境自杀**:`FATAL: GPU process isn't usable. Goodbye.` → `--in-process-gpu`(GPU 逻辑跑浏览器进程内,不 fork)。另给可写 HOME/XDG(`ProtectHome=yes` 下 /root 只读会刷 permission denied 噪声)。 另加**存活探针**:此前只等 CDP 端口就报「已就绪」,即便浏览器已死也报成功 —— **谎报比直接报错更坏**(运维会以为是自己操作的问题)。现在会如实报出 `浏览器 CDP 无应答:...FATAL: GPU process isn't usable`。 ## 测试与生产抢资源的修复 生产 `PrivateTmp=yes` 下 Xvfb `:91` 的锁在**服务自己的 /tmp** 里,宿主测试看不到 ⇒ `xdisplayBusy(91)` 判"空闲"⇒ 测试去抢 91 ⇒ `Xvfb 未就绪`。 → 显示器起始号可被 `LLMSPROXY_XDISPLAY_BASE` 覆盖,测试台统一用 300+; `TestXDisplaySelectionReclaimsStaleLocks` 的号段也跟随(否则它造出的陈旧锁**根本 不会被扫描到**,断言永远失败 —— 那是判据与被测对象脱节)。 (端口不需要覆盖点:端口是全局资源,`portFree` 查 /proc/net/tcp 看得见生产占用。) ## 判据 - `TestSweepSessionsHelpersAreForwardDeclared`:三助手必须前向声明 + 定义必须是 赋值式 + 声明必须**先于**使用。 - `TestBrowserStackVerifiesLivenessNotJustPort`:必须有 CDP 真应答判据 + /proc 存活 + 日志真因 + 失败时回收整组进程。 - `TestBrowserStackUsesInProcessGPU`:`--in-process-gpu` / 可写 HOME/XDG 不得被删。 - `TestChannelsVncStackEndToEnd` / `TestXDisplaySelection*`:真实起栈验证。 ## 端到端验证 `bash deploy.sh` → 生产 `active` / `NRestarts=0`;真实登录请求 → `server_browser=True`, VNC 上**完整渲染 TRAE 登录页**(手机号登录 / 抖音 / 苹果 / 企业账号登录全部可见), 标题 `登录 | TRAE - The Real AI Engineer`。 回归:9 个包全绿。
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)
|
||
}
|
||
}
|