diff --git a/internal/core/channels_xdisplay_test.go b/internal/core/channels_xdisplay_test.go new file mode 100644 index 0000000..4c3125a --- /dev/null +++ b/internal/core/channels_xdisplay_test.go @@ -0,0 +1,191 @@ +package core + +import ( + "os" + "strconv" + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。 +// +// 现场(2026-10-13):/tmp/.X-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) + } +} diff --git a/internal/core/kernel_actions_test.go b/internal/core/kernel_actions_test.go index cf3a6f7..8224d3e 100644 --- a/internal/core/kernel_actions_test.go +++ b/internal/core/kernel_actions_test.go @@ -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 } diff --git a/internal/gateway/mutation_guard_test.go b/internal/gateway/mutation_guard_test.go new file mode 100644 index 0000000..364fbe1 --- /dev/null +++ b/internal/gateway/mutation_guard_test.go @@ -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) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 66a2612..3b47244 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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-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/,**不用 os.execute("kill -0 …")**: + -- 后者靠 shell 退出码,而这个 VM 里 os.execute 的返回值形态不可靠 + -- (判据实测咬了:陈旧锁没被回收 → 号仍然占着)。 + -- /proc/ 存在即为进程存在(僵尸也在,偏保守,不会误回收活着的号)。 + 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-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