mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user