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

@ -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