Files
ModelRouter/internal/core/channels_vnc_lifecycle_test.go
JianFeeeee 226493b5ae fix(部署+插件): VNC 兜底登录**真正可用** —— 放开浏览器栈不需要的加固 + 修 3 个插件缺陷
用户原话:「全部放开,插件不让用这些还叫插件吗」。方向是对的:插件架构的边界
应当是**内核暴露给插件的 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 个包全绿。
2026-10-15 08:22:57 +08:00

71 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"strings"
"testing"
)
// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。
//
// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个
// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是
// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸:
//
// attempt to call global 'reapLoopback' (a nil value)
//
// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500,
// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。
// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。
func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// ① 必须存在对这三个助手的前向声明
if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") {
t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " +
"`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" +
"正常、第二次 500,且浏览器栈不会被回收)")
}
// ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function`
// —— 那会遮蔽前向声明,效果与没声明完全相同。
for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} {
if strings.Contains(b, "local function "+fn+"(") {
t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+
"再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn)
}
if !strings.Contains(b, "\n"+fn+" = function(") {
t.Errorf("找不到 %s 的赋值式定义", fn)
}
}
// ③ 声明必须**先于**使用(行号比较):这是前向引用的核心
decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser")
use := strings.Index(b, "\n reapLoopback(id)\n")
if decl < 0 || use < 0 {
t.Fatalf("找不到声明(%d)或使用(%d)", decl, use)
}
if decl > use {
t.Error("前向声明必须在**使用之前** —— 否则 nil 调用")
}
}
// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。
func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// CDP 真应答(端口在听 ≠ 服务可用)
if !strings.Contains(b, "browserUtil.cdpAlive") {
t.Error("必须用 CDP 真应答判活,不能只看端口")
}
// 进程真存活
if !strings.Contains(b, "/proc/") {
t.Error("必须检查 chromium 进程仍存活(/proc/<pid>)")
}
// 失败要把日志真因带出来(而不是一句"启动失败")
if !strings.Contains(b, "browserUtil.readTail") {
t.Error("启动失败必须带出日志真因")
}
}