fix(channels): 修服务端浏览器栈的四处泄漏(含宿主 SHM 耗尽)

这些缺陷让「测试单独跑绿、全量跑红」,在生产同样会让登录用到一半失败。

## ① 宿主级:SIGKILL 杀 X 进程 → SysV SHM 段耗尽
X 服务用 MIT-SHM 与客户端通信,被强杀时这些段无人释放,成为系统级孤儿段
(nattch=0 但占着 kernel.shmmni 名额)。实测单个 Xvfb 约 84 段,上限 4096
⇒ 约 48 次异常退出即耗满。用满后**宿主上任何**新进程 shmget 都失败:
x11vnc 从 0.2s 能监听变成永远起不来(表现为「x11vnc 端口未就绪」)。
修:回收改为**先 SIGTERM、等 0.4s、再 SIGKILL**,让 X 自己释放 SHM。
实测 core 全包前后 shm 84 → 84(零泄漏)。

## ② 显示器锁泄漏 → 号段被烧穿
强杀留下 /tmp/.X<n>-lock,而选号是「锁/socket 存在就当被占」⇒ 每个被强杀的
显示器永久烧掉一个号;累积满 91 起后 pickFree 扫不到空位就 return start(91)
→ Xvfb 报 “Server is already active for display 91”(实测 41/41 全占)。
修:pickFreeX 回收陈旧锁(用 /proc/<pid> 判存活,不信 shell 退出码)+ 扫 200 个号
+ 扫不到如实报错;收尾清掉自己占的锁与 socket。

## ③ chromium profile 复用 → SingletonLock 卡死
数据目录原用 seq 编号,插件重启后 seq 重来 ⇒ 复用同目录 ⇒ 残留 SingletonLock
让 chromium 只打印 “Opening in existing browser session.” 就退出,新端口永不监听
→ 报「chromium 端口未就绪」。
修:目录名用唯一 tag(时间+随机),并在收尾清掉。

## ④ callback 流漏收浏览器栈
callback 流在 start_login 就拉起服务端浏览器栈,但没有任何路径负责回收
⇒ 每次放弃登录都留一套(实测一轮 core 全包攒 7 套 websockify+x11vnc)。
修:会话过期时若栈属于该会话就收掉;起新栈前先收旧栈(单会话栈);
kernelTestCore 统一 t.Cleanup 调 stop_browser。

## 判据
- TestXDisplaySelectionReclaimsStaleLocks:陈旧锁必须被**回收复用**(只跳过不算通过)
- TestXDisplaySelectionKeepsLiveLocks:活锁不得被回收(反向)
- 两个变异(去掉收尾清理 / 有锁就当被占)均被抓

## 另:变异测试会污染生产源码,加进程级兜底
discover_mutate_test.go 会改写 internal/gateway/discover.go,靠 defer 回滚 ——
进程被强杀(go test 超时 / Ctrl-C / 误杀 shell)时 defer 不执行,变异体留在工作区,
下次运行会把它当"原始源码",所有裁决静默失效(本仓真实踩过两次)。
新增 mutation_guard_test.go:进包即自愈已知变异体。
This commit is contained in:
JianFeeeee
2026-10-13 15:24:07 +08:00
parent 6fe6014d92
commit 41ff6d1be3
4 changed files with 470 additions and 12 deletions

View File

@ -0,0 +1,191 @@
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)
}
}

View File

@ -46,6 +46,24 @@ func kernelTestCore(t *testing.T) *Core {
if err != nil {
t.Fatal(err)
}
// 测试结束**必须**把插件拉起的服务端浏览器栈收掉。
// 为何必需:callback 流(trae/gemini/有道)在 start_login 时就起一整套
// Xvfb+chromium+x11vnc+websockify,而绝大多数测试不会显式 stop_browser、
// 会话 TTL 也还没到 —— 于是一轮 core 全包跑完就攒出 7 套常驻进程
// (实测 websockify×7 + x11vnc×7 + 7 个 profile 目录),把显示器号与端口
// 段逐步耗光,后续测试报「chromium/websockify 端口未就绪」。
// 在这一处统一收掉,比让每个测试各自记得更可靠。
t.Cleanup(func() {
ps := c.Plugins()
if ps == nil {
return
}
for _, name := range []string{"channels"} {
_, _ = ps.InvokeHandler(name, "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
}
})
t.Cleanup(c.Close)
return c
}

View File

@ -0,0 +1,71 @@
package gateway
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 变异测试会**改写生产源码** `discover.go`(discover_mutate_test.go),
// 靠 defer 把原文件写回。这在进程被强杀时不成立:
//
// - `go test -timeout` 超时 → 测试进程被杀
// - Ctrl-C / 后台任务被 kill
// - 跑测试的 shell 被误杀(本仓真实踩过:`pkill -f` 匹配到执行它的那条命令行)
//
// 后果不是"这次测试没跑完",而是**变异体被留在工作区里**:下一次运行会把
// 它当成"原始源码"捕获,于是基线本身就是变异体,所有裁决静默失效;更糟的是
// 开发者可能把它当成自己的改动提交上去(真实踩过两次:`b == b && b != b`
// 这种恒假的变异体出现在 `git status` 里)。
//
// 兜底:每次进入本包的测试都先把已知变异体的痕迹清掉(回滚到提交版本),
// 这样即使上一次被强杀,本次运行也不会建立在污染之上。TestRealMutants 自己
// 也会在脏树上拒绝运行,但那是"拒绝",这里是"自愈"。
func TestMain(m *testing.M) {
restoreLeakedMutants()
os.Exit(m.Run())
}
// leakedMutants 列出「变异体特征 → 它对应的正确原文」。
// 只处理能**确定判定**的形态:出现特征即说明上一次没回滚干净。
var leakedMutants = []struct {
file string
bad string
good string
}{
{
file: "discover.go",
bad: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {",
good: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {",
},
{
file: "discover.go",
bad: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
good: "\t\tif seenPair[pair] || seenURL[base+path] {",
},
}
func restoreLeakedMutants() {
wd, err := os.Getwd()
if err != nil {
return
}
for _, m := range leakedMutants {
p := filepath.Join(wd, m.file)
b, err := os.ReadFile(p)
if err != nil {
continue
}
s := string(b)
if !strings.Contains(s, m.bad) {
continue
}
// 只替换回原文,不猜别的差异 —— 保守,避免把开发中的改动一起冲掉。
fixed := strings.Replace(s, m.bad, m.good, 1)
if fixed == s {
continue
}
_ = os.WriteFile(p, []byte(fixed), 0o644)
}
}

View File

@ -1968,6 +1968,15 @@ local function sweepSessions()
-- 会话过期时要顺带回收它为回调起的 loopback 接收器 —— 否则用户放弃
-- 登录后那些 python 监听会一直挂着(进程泄漏,且白占端口)。
reapLoopback(id)
-- 同一道理:callback 流会在服务端起一整套浏览器栈(Xvfb/chromium/VNC)。
-- 会话过期就是「用户已经放弃」,必须把栈收掉 —— 否则每次放弃登录都留一套
-- 进程常驻(实测:重复跑测试/重复发起登录会攒出 7 套 websockify+x11vnc)。
if s.owns_browser then
local cur = browserStack()
if cur.owner == id then
reapBrowser()
end
end
sessions()[id] = nil
end
end
@ -2011,15 +2020,154 @@ end
-- reapBrowser 回收本插件拉起的整组进程。进程是插件起的,插件负责杀 ——
-- 留一个孤儿 chromium 会一直占着显示与内存,且下次 spawn 会撞端口。
-- killProcsMatching(needle):杀掉 cmdline 里含 needle 的进程(含自己不算)。
--
-- 为什么不用记录的 PID:`setsid` 在「已是进程组长」时会 fork,shell 的作业控制
-- 也会另开进程组 —— 于是 `$!` 未必是真正的服务进程。实测后果:kill 记录的 PID
-- 杀不掉 chromium 的 zygote/renderer,它们继续活着并重建 profile 目录,
-- 一次 core 全包跑出 7 套残留栈(websockify×7 + x11vnc×7 + 7 个 profile 目录)。
--
-- 为什么不用 pkill -f:那个模式会出现在**我们自己这条 shell 命令行**里 →
-- pkill 把执行它的 shell 一起杀掉(真实踩过两次,命令链直接中断)。
-- 这里直接读 /proc(不经 shell 传模式),因此不会自匹配。
local function killProcsMatching(needle)
if not needle or needle == "" then return 0 end
local h = io.popen("ls /proc 2>/dev/null")
if not h then return 0 end
local victims = {}
for entry in h:lines() do
if entry:match("^%d+$") then
local f = io.open("/proc/" .. entry .. "/cmdline", "r")
if f then
local raw = f:read("*a") or ""
f:close()
-- cmdline 以 NUL 分隔;换成空格以便匹配
local cmd = raw:gsub("%z", " ")
if cmd ~= "" and cmd:find(needle, 1, true) then
victims[#victims + 1] = entry
end
end
end
end
h:close()
-- 同样先 TERM 再 KILL:chromium 也持有 X 的 SHM 段,强杀会留孤儿段
-- (见 reapBrowser 关于 shmmni 耗尽的说明)。
for _, pid in ipairs(victims) do
os.execute("kill -TERM " .. pid .. " 2>/dev/null")
end
if #victims > 0 then
os.execute("sleep 0.3")
end
for _, pid in ipairs(victims) do
os.execute("kill -9 " .. pid .. " 2>/dev/null")
end
return #victims
end
local function reapBrowser()
local b = browserStack()
local sess = b.session
local disp = b.display or (sess and sess.display)
local prof = b.profile
-- 按**唯一标识**回收(显示器号 / profile 目录),不依赖记录的 PID ——
-- 理由见 killProcsMatching 注释($! 未必是真正的服务进程,只信它会留下
-- zygote/renderer 与 x11vnc/websockify 残留)。这些标识都是本次会话独有的,
-- 因此不会误杀别的会话。
-- 按 **profile 目录**匹配杀 chromium 及其子进程:tag 是「时间+随机」故全局唯一,
-- 不会误杀别的会话。**不能**按显示器号匹配 —— 显示器号会在空出后被复用,
-- 于是 A 会话的收尾会把 B 会话正在用的 `Xvfb :91` 一起杀掉
-- (实测后果:chromium 报 "X connection error",栈起不来)。
if prof then
killProcsMatching("user-data-dir=" .. prof)
end
-- Xvfb / x11vnc / websockify 命令行里没有唯一标识,只信记录的 PID。
--
-- **先 SIGTERM 再 SIGKILL**,不能直接 -9:X 服务的 MIT-SHM 共享内存段
-- 被强杀时无人释放,会变成系统级孤儿段(nattch=0 但占着 shmmni 名额)。
-- 实测:单个 Xvfb 约占 84 段,内核 kernel.shmmni=4096 → 约 48 次异常退出
-- 就把它用满,此后**宿主上任何**新进程 shmget 都返回
-- “No space left on device”(x11vnc 就从 0.2s 能起变成永远起不来)。
-- 优雅终止让 X 自己清理这些段。
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -TERM " .. tostring(pid) .. " 2>/dev/null")
end
if #(b.pids or {}) > 0 then
os.execute("sleep 0.4")
end
for _, pid in ipairs(b.pids or {}) do
os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null")
end
-- 杀掉自己占的显示器后必须清掉它的锁与 socket。
-- 为什么必需:Xvfb 被 SIGKILL(本函数就是这么杀的)来不及清理
-- /tmp/.X<n>-lock,留着就成了**陈旧锁**。而选号逻辑是「锁/socket 存在就当被占」,
-- 于是每个被 KILL 的显示器都会永久烧掉一个号 —— 反复跑测试/反复登录,
-- 91 起始的号会被耗光,之后每次都挑中 91 并报
-- “Server is already active for display 91”(真实踩过:41/41 全占)。
-- 用 b.display 而不是 b.session.display:**失败路径也在 session 赋值之前就
-- 调本函数**(Xvfb 起不来、端口未就绪…),那时只有 display 已知。
local disp = b.display or (b.session and b.session.display)
if disp then
os.remove("/tmp/.X" .. tostring(disp) .. "-lock")
os.remove("/tmp/.X11-unix/X" .. tostring(disp))
end
-- profile 目录也一并清:tag 是唯一的,不清就会无限累积(每次登录留一份
-- 几十 MB 的 chromium profile 在 /tmp)。
if b.profile then
os.execute("rm -rf " .. b.profile .. " 2>/dev/null")
end b.profile = nil
b.display = nil
b.pids = {}
b.session = nil
end
-- xdisplayBusy(n):显示器号是否真被占。
-- 判存在用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,io.open 对它
-- 返回 nil(打不开)→ 会误判“空闲”→ 又选中被占的号(这个坑真实踩过)。
local function xdisplayBusy(n)
local lock = "/tmp/.X" .. tostring(n) .. "-lock"
local ok = os.execute('[ -e "' .. lock .. '" ] 2>/dev/null')
local hasLock = (ok == true or ok == 0)
if not hasLock then
local sok = os.execute('[ -e "/tmp/.X11-unix/X' .. tostring(n) .. '" ] 2>/dev/null')
return sok == true or sok == 0
end
-- 锁在,但记录的 PID 可能早死了 —— 那就是陈旧锁,**可以回收**。
-- 不回收的话号会越用越少(见 reapBrowser 注释)。
local f = io.open(lock, "r")
if not f then return true end
local pid = (f:read("*l") or ""):gsub("%s", "")
f:close()
if pid == "" then return true end
-- 存活判定用 /proc/<pid>,**不用 os.execute("kill -0 …")**:
-- 后者靠 shell 退出码,而这个 VM 里 os.execute 的返回值形态不可靠
-- (判据实测咬了:陈旧锁没被回收 → 号仍然占着)。
-- /proc/<pid> 存在即为进程存在(僵尸也在,偏保守,不会误回收活着的号)。
if pid:match("^%d+$") then
local alive = io.open("/proc/" .. pid .. "/stat", "r")
if alive then
alive:close()
return true
end
else
return true -- 锁里不是数字(异常内容),当被占处理
end
-- 陈旧锁:清掉它和可能残留的 socket,让这个号重新可用。
os.remove(lock)
os.remove("/tmp/.X11-unix/X" .. tostring(n))
return false
end
-- pickFreeX(start, span):挑一个真正空闲的显示器号。
-- 与旧 pickFree(91, 40) 的差别:① 陈旧锁会被回收(而不是永久占号);
-- ② 扫不通时**如实报错**,不再 `return start` —— 返回一个已知被占的号只会
-- 让 Xvfb 报一句难以归因的 “already active for display N”。
local function pickFreeX(start, span)
for n = start, start + span do
if not xdisplayBusy(n) then return n end
end
return nil
end
local function browserDeps()
local missing, present = {}, {}
for _, d in ipairs(BROWSER_DEPS) do
@ -2457,6 +2605,13 @@ local function spawnBrowser(start_url)
end
b.seq = b.seq + 1
local seq = b.seq
-- 每次会话一个唯一标记:用于 chromium 的 user-data-dir 与日志文件名。
-- 为何不能用 seq:seq 在插件重启后从 1 重新开始,于是会**复用同一个
-- user-data-dir**,而里面残留的 `SingletonLock` 会让 chromium 认为
-- “已有实例在跑”——它只打印一句 `Opening in existing browser session.` 就退出,
-- 新端口永不监听 → 15s 后报「chromium 端口未就绪」(真实踩过)。
-- 显示器锁是同一类坑(见 pickFreeX),此处用不可复用的标记从根上避开。
local tag = tostring(os.time and os.time() or 0) .. "-" .. newToken():sub(1, 8)
-- display / 端口取号:**探测空闲**,不能按 seq 取模。
-- -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X<n>-lock)在
-- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接
@ -2478,10 +2633,16 @@ local function spawnBrowser(start_url)
-- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil
return ok == true or ok == 0
end
local disp = pickFree(91, function(n)
return not pathExists("/tmp/.X" .. n .. "-lock")
and not pathExists("/tmp/.X11-unix/X" .. n)
end)
-- 显示器号:用 pickFreeX(会回收陈旧锁,且扫不到就如实报错),
-- 不再用“存在就当被占”的 inline 判定 —— 那会把每个被 KILL 的显示器永久烧掉。
local disp = pickFreeX(91, 200)
if not disp then
return nil, "没有可用的显示器号(/tmp/.X*-lock 全被占用)"
end
-- 立即记下:后续任何一步失败都会 reapBrowser(),它靠 b.display 才能清掉
-- 这个号的锁(否则 Xvfb 未起来就失败时,残留锁会把号白烧掉)。
local bs = browserStack()
bs.display = disp
local function portFree(p)
-- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口)
for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do
@ -2535,17 +2696,28 @@ local function spawnBrowser(start_url)
local chromium = deps.chromium
local novnc = deps.novnc
-- chromium 数据目录必须**每次会话全新的**:复用同一目录时,上一次被强杀
-- 留下的 SingletonLock 会让它直接退出(见 tag 的注释)。这里不只是换新目录,
-- 还把可能残留的单例锁与陈旧 profile 清掉,双保险。
local profileDir = "/tmp/llmsproxy-ch-" .. tag
os.execute("rm -rf " .. profileDir .. " 2>/dev/null")
-- 顺带清掉**陈旧**的 profile 目录:正常路径由 reapBrowser 清,但进程被强杀
-- (go test 超时、插件崩)时来不及清,而 tag 唯一意味着它们会一直累积。
-- 只清 6 小时前的,不会碰到别的会话正在用的。
os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null")
-- 记下来供 reapBrowser 收尾清理(失败路径也要清,否则 tag 唯一=无限累积)。
browserStack().profile = profileDir
local steps = {
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%d.log 2>&1 & echo $!"):format(disp, seq), nil, 0, 0 },
{ "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d "
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 },
{ "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
.. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* "
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%d.log 2>&1 & echo $!"):format(
chromium, seq, cdpPort, disp, url, seq), "127.0.0.1", cdpPort, 15000 },
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format(
chromium, profileDir, cdpPort, disp, url, tag), "127.0.0.1", cdpPort, 15000 },
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
.. ">/tmp/llmsproxy-x11vnc-%d.log 2>&1 & echo $!"):format(
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, seq), "127.0.0.1", vport, 8000 },
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%d.log 2>&1 & echo $!"):format(
novnc, bind, wport, vport, seq), bind, wport, 8000 },
.. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format(
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 },
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%s.log 2>&1 & echo $!"):format(
novnc, bind, wport, vport, tag), bind, wport, 8000 },
}
-- ① 先起 Xvfb(其余三步依赖 X display)
local xvfbStep = steps[1]
@ -2902,6 +3074,11 @@ function plugin.start_login(req)
-- 没有浏览器栈时**如实说明**,不假装这个 URL 在用户机器上能用。
local deps = browserDeps()
if deps.ok then
-- 单会话栈:起新的之前先收掉旧的(否则逐次登录会叠加多套进程)。
local cur = browserStack()
if cur.session then reapBrowser() end
cur.owner = sess.id
sess.owns_browser = true
local bs, berr = spawnBrowser(out.url)
if not berr then
out.view_url = bs.view_url
@ -4726,6 +4903,7 @@ end
function plugin.stop_browser(req)
local had = browserStack().session ~= nil
reapBrowser()
browserStack().owner = nil
if had then logEvent("服务端浏览器已回收") end
return { status = 200, json = { ok = true, stopped = had } }
end