From b72727451d5d317144adb719f9617b83bf2c4fc7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 6 Oct 2026 12:59:55 +0800 Subject: [PATCH] fix: share Xvfb display across browser instances --- browser_gateway/runtime.py | 45 +++-- browser_gateway/test_runtime.py | 97 ++++++++++ docs/shared-xvfb-window-control.md | 278 +++++++++++++++++++++++++++++ 3 files changed, 408 insertions(+), 12 deletions(-) create mode 100644 docs/shared-xvfb-window-control.md diff --git a/browser_gateway/runtime.py b/browser_gateway/runtime.py index 250e918..f166c61 100644 --- a/browser_gateway/runtime.py +++ b/browser_gateway/runtime.py @@ -782,6 +782,7 @@ class NativeRuntimeManager: record = self._require_record(alias, generation) self._refresh(record) if record.state != "running" or not self._ready(record): + LOG.warning("runtime_not_ready alias=%s runtime_id=%s network_id=%s state=%s cleanup_state=%s cdp_port=%s browser_unit=%s xvfb_unit=%s", record.alias, record.runtime_id, record.network_id, record.state, record.cleanup_state, record.cdp_port, record.browser_unit, record.xvfb_unit) raise BrowserRuntimeError("browser runtime is not ready", 503, record.network_id) return record @@ -790,9 +791,21 @@ class NativeRuntimeManager: records = self._records() result = [] for record in records: - if record.state not in {"released"}: - self._refresh(record) - result.append(self._public(record, self._ready(record) if record.state in {"running", "degraded"} else False)) + lock = FileLock(self.state_dir / "locks" / f"alias-{record.alias}.lock") + if not lock.acquire(): + LOG.debug("runtime_observation_busy alias=%s runtime_id=%s state=%s", record.alias, record.runtime_id, record.state) + result.append(self._public(record, False)) + continue + try: + # A lifecycle operation may have finished after the initial snapshot. + # Refresh only a fresh record protected by the same cross-process lock. + record = self._read_record(self._record_path(record)) + if record.state != "released": + self._refresh(record) + ready = self._ready(record) if record.state in {"running", "degraded"} else False + result.append(self._public(record, ready)) + finally: + lock.release() return result def retry_cleanup(self, alias: str, generation: Mapping[str, Any]) -> None: @@ -1051,6 +1064,12 @@ class NativeRuntimeManager: record.xvfb_pid = xvfb.pid if record.state == "starting": record.state = "degraded" + elif record.state == "degraded" and record.cleanup_state != "pending" and self._ready(record): + record.state = "running" + record.cleanup_state = "none" + record.cleanup_error = "" + record.updated_at = self.clock() + LOG.info("runtime_readiness_recovered alias=%s runtime_id=%s network_id=%s cdp_port=%s", record.alias, record.runtime_id, record.network_id, record.cdp_port) self._write(record) def _ready(self, record: RuntimeRecord) -> bool: @@ -1059,7 +1078,8 @@ class NativeRuntimeManager: try: version = self._get_json(record.cdp_port, "/json/version") targets = self._get_json(record.cdp_port, "/json/list") - except (OSError, ValueError, BrowserRuntimeError): + except (OSError, ValueError, BrowserRuntimeError) as exc: + LOG.debug("runtime_cdp_not_ready alias=%s runtime_id=%s cdp_port=%s error=%s", record.alias, record.runtime_id, record.cdp_port, exc) return False return isinstance(version, dict) and isinstance(targets, list) and any( isinstance(target, dict) and target.get("type") == "page" for target in targets @@ -1218,15 +1238,16 @@ class NativeRuntimeManager: with suppress(FileNotFoundError): temporary.unlink() + def _read_record(self, path: Path) -> RuntimeRecord: + try: + return RuntimeRecord.from_dict(json.loads(path.read_text(encoding="utf-8"))) + except OSError as exc: + raise BrowserRuntimeError("runtime metadata cannot be read", 500) from exc + except json.JSONDecodeError as exc: + raise BrowserRuntimeError("runtime metadata is invalid", 500) from exc + def _records(self) -> list[RuntimeRecord]: - result = [] - for path in sorted((self.state_dir / "runtimes").glob("*/runtime.json")): - try: - result.append(RuntimeRecord.from_dict(json.loads(path.read_text(encoding="utf-8")))) - except OSError as exc: - raise BrowserRuntimeError("runtime metadata cannot be read", 500) from exc - except json.JSONDecodeError as exc: - raise BrowserRuntimeError("runtime metadata is invalid", 500) from exc + result = [self._read_record(path) for path in sorted((self.state_dir / "runtimes").glob("*/runtime.json"))] return sorted(result, key=lambda item: (item.created_at, item.alias)) def _find_alias(self, alias: str, include_released: bool) -> RuntimeRecord | None: diff --git a/browser_gateway/test_runtime.py b/browser_gateway/test_runtime.py index 8b328a6..92a081b 100644 --- a/browser_gateway/test_runtime.py +++ b/browser_gateway/test_runtime.py @@ -448,6 +448,103 @@ class NativeRuntimeManagerTests(unittest.TestCase): self.assertEqual(result["state"], "stopped") self.assertEqual(self.units.commands, []) + def test_listing_during_start_cannot_overwrite_runtime_readiness(self) -> None: + from threading import Event, Thread + + waiting, finish = Event(), Event() + outcomes: list[object] = [] + + def wait(record: object) -> None: + waiting.set() + if not finish.wait(2): + raise AssertionError("startup test did not release CDP wait") + + def create() -> None: + try: + outcomes.append(self.manager.create(self.payload())) + except Exception as exc: + outcomes.append(exc) + + self.manager._wait_for_cdp = wait + thread = Thread(target=create) + thread.start() + try: + self.assertTrue(waiting.wait(1)) + runtime_file = next(self.state.glob("runtimes/*/runtime.json")) + before = runtime_file.read_text() + stale_starting = self.manager._records() + public = self.manager.list_public()[0] + self.assertEqual(public["state"], "starting") + self.assertFalse(public["ready"]) + self.assertEqual(runtime_file.read_text(), before) + finally: + finish.set() + thread.join(2) + self.assertFalse(thread.is_alive()) + self.assertEqual(len(outcomes), 1) + self.assertIsInstance(outcomes[0], dict) + result = outcomes[0] + self.manager._ready = lambda record: True + with patch.object(self.manager, "_records", return_value=stale_starting): + public = self.manager.list_public()[0] + self.assertEqual(public["state"], "running") + self.assertTrue(public["ready"]) + verified = self.manager.require_generation("account-a", result) + self.assertEqual(verified.state, "running") + self.assertEqual(verified.runtime_id, result["runtime_id"]) + + def test_listing_reloads_metadata_after_lifecycle_transition(self) -> None: + result = self.manager.create(self.payload()) + stale = self.manager._records() + self.manager.change_state("account-a", "stop", result) + with patch.object(self.manager, "_records", return_value=stale): + public = self.manager.list_public()[0] + self.assertEqual(public["state"], "stopped") + self.assertEqual(public["cleanup_state"], "cleaned") + self.assertEqual(self.manager._records()[0].state, "stopped") + + def test_verified_live_degraded_runtime_recovers_without_restart(self) -> None: + result = self.manager.create(self.payload()) + record = self.manager._records()[0] + record.state = "degraded" + self.manager._write(record) + self.manager._ready = lambda record: True + commands_before = list(self.units.commands) + verified = self.manager.require_generation("account-a", result) + self.assertEqual(verified.state, "running") + self.assertEqual(verified.runtime_id, result["runtime_id"]) + self.assertEqual(self.units.commands, commands_before) + self.assertTrue(all(status.active for status in self.units.units.values())) + + def test_degraded_runtime_with_pending_cleanup_is_not_recovered(self) -> None: + result = self.manager.create(self.payload()) + record = self.manager._records()[0] + record.state = "degraded" + record.cleanup_state = "pending" + self.manager._write(record) + self.manager._ready = lambda record: True + with self.assertRaises(BrowserRuntimeError) as caught: + self.manager.require_generation("account-a", result) + self.assertEqual(caught.exception.status, 503) + self.assertEqual(self.manager._records()[0].state, "degraded") + self.assertTrue(all(status.active for status in self.units.units.values())) + + def test_listing_respects_cross_process_alias_lock(self) -> None: + from .runtime import FileLock + + self.manager.create(self.payload()) + runtime_file = next(self.state.glob("runtimes/*/runtime.json")) + before = runtime_file.read_text() + external = FileLock(self.state / "locks" / "alias-account-a.lock") + self.assertTrue(external.acquire()) + try: + with patch.object(self.manager, "_refresh", side_effect=AssertionError("busy alias refreshed")): + public = self.manager.list_public()[0] + self.assertFalse(public["ready"]) + self.assertEqual(runtime_file.read_text(), before) + finally: + external.release() + def test_timeout_marks_runtime_cleanup_pending(self) -> None: self.manager.ready_timeout = 0.001 self.manager._wait_for_display = NativeRuntimeManager._wait_for_display.__get__(self.manager) diff --git a/docs/shared-xvfb-window-control.md b/docs/shared-xvfb-window-control.md new file mode 100644 index 0000000..2ec8ced --- /dev/null +++ b/docs/shared-xvfb-window-control.md @@ -0,0 +1,278 @@ +# 共享 Xvfb 与指定 profile 窗口操作:讨论结论 + +## 1. 文档定位与完成标准 + +本文汇总 gateway 的 DISPLAY 配置、共享 Xvfb 的资源收益、窗口定位和 Openbox 激活机制,供后续需求迭代使用。 + +- **当前事实**:依据现有代码、配置样例及本次讨论期间的只读查询。 +- **建议方案**:尚未实现,不能视为当前产品能力或已经批准的开发任务。 +- 本次仅新增文档,不修改代码、配置,不启动或激活浏览器,不发送鼠标事件。 +- 文档完成标准:覆盖讨论中的配置、限制、资源、两种输入方式、profile/窗口映射、风险、待办与验收条件,并明确证据边界。 + +相关资料: + +- [现有验证记录](native-browser-verification.md) +- [现有实施计划](native-browser-implementation-plan.md) +- [现有浏览器变更评审](native-browser-change-review.md) + +本文讨论的新建议不改变上述文档已有的完成状态,也不表示共享 DISPLAY 或窗口激活功能已经通过验收。 + +## 2. 当前 gateway 的 DISPLAY 行为 + +### 2.1 默认模式 + +- 每个 runtime 启动独立 Xvfb。 +- 默认从 `:100` 到 `:199` 按顺序分配可用 DISPLAY。 +- 当前默认屏幕配置为 `1280x720x24`。 +- Chrome 启动时显式设置对应 `DISPLAY`,不是直接继承 gateway 进程的 `DISPLAY`。 +- 默认分配范围目前没有对应环境变量,只有 runtime manager 的构造参数。 + +### 2.2 外部 DISPLAY 模式 + +在 gateway 环境配置中设置: + +```ini +RUNTIME_EXTERNAL_DISPLAY=99 +``` + +含义:复用已经存在的 `DISPLAY=:99`。 + +- 配置值是正整数,不带冒号。 +- 外部 Xvfb 必须事先运行;gateway 不负责启动或停止它。 +- systemd 部署的环境文件通常是 `~/.config/creatorhub/browser-gateway.env`。 +- 修改配置后需重启 gateway;已经运行的 Chrome 不会自动切换 DISPLAY。 +- 当前可用性检查涉及 X11 socket 是否存在,不能将其等同于完整的显示服务健康检查。 + +配置和实现依据: + +- `browser_gateway/server/http.py`:环境配置解析。 +- `browser_gateway/runtime.py`:DISPLAY 分配、启动、恢复及释放。 +- `deploy/browser-gateway.env.example`:配置样例。 +- `deploy/creatorhub-browser-gateway.service.in`:环境文件加载。 + +### 2.3 当前共享限制 + +**Xvfb 本身支持多个 Chrome 共用同一个 DISPLAY;禁止共享是 gateway 当前的应用限制。** + +现有外部模式包含两层占用处理: + +1. `_reserve_display` 检查运行记录:如果其他未 `released` 的 runtime 已记录同一 DISPLAY,则拒绝启动。检查不只限于正在运行的浏览器,`stopped` 记录也可能触发。 +2. `DisplayAllocator.reserve_existing` 对 DISPLAY 使用独占文件锁;当前实现会在浏览器启动后释放 display lease,不能把它描述为贯穿运行期的独占锁。 + +若允许共享,应同时审查记录检查和外部模式的锁流程,不能只删除报错分支。默认模式自行启动 Xvfb 的分配锁仍有必要保留,避免两个启动流程竞争同一 DISPLAY。 + +## 3. 每增加一个 Xvfb 的资源开销 + +### 3.1 本次讨论期间的运行快照 + +| 屏幕配置 | 进程显示内存 RSS | 共享内存按比例分摊后的 PSS | 短时 CPU 均值 | +| --- | ---: | ---: | ---: | +| `1280x720x24` | 约 64.8 MiB | 约 32.0 MiB | 0.00% | +| `1920x1080x24` | 约 53.3 MiB | 约 35.5 MiB | 0.33% | + +测量来自现有 Xvfb 的 `/proc//smaps_rollup` 和短时间 `pidstat` 采样,未为测量启动新进程。 + +解释与边界: + +- RSS 含共享页,不能简单相加得出额外物理内存;PSS 是当前分摊值,也不是新增一个进程的严格边际成本。 +- 两个进程运行历史和负载不同,不能根据表格推导较高分辨率反而更省内存。 +- CPU 百分比按单核口径理解;本次近乎空闲的采样不代表视频、动画或频繁重绘时的消耗。 +- Chrome、VNC、Openbox 的资源消耗均不包含在上述 Xvfb 数字中。 +- 未保留完整的负载条件和连续曲线,因此这些数据是运行快照,不是性能基准。 + +### 3.2 容量规划建议 + +可暂按 **每个 Xvfb 预留 50~70 MiB** 估算,10 个约 **0.5~0.7 GiB**。该数字是当前快照下的粗略预留值,不是上限或保证。 + +24 位色的像素通常按 32 位存储,`1280×720` 单帧约 `3.52 MiB`,但 Xvfb 还包含绘图资源、字体、连接和缓存,不能只用单帧大小估算进程内存。 + +共享 DISPLAY 能减少独立 Xvfb 的重复固定开销;不会消除各个 Chrome 的内存,也不会让所有窗口绘图资源的占用归零。是否值得共享,仍需结合目标并发数和实际页面负载判断。 + +## 4. 共享 DISPLAY 的收益与代价 + +| 项目 | 独立 DISPLAY | 共享 DISPLAY | +| --- | --- | --- | +| Xvfb 固定开销 | 每个 runtime 一份 | 多个 runtime 共用 | +| Chrome 数据目录、控制端口 | 各自独立 | 仍须各自独立 | +| 桌面鼠标、键盘焦点、剪贴板 | 各自独立 | 全部共享 | +| VNC 可见窗口 | 单个浏览器为主 | 多窗口可能遮挡 | +| Xvfb 故障影响范围 | 对应 runtime | 所有共享者 | +| 桌面输入协调 | 每个 DISPLAY 内协调 | 所有共享者统一协调 | + +**建议方向,尚未实现:默认保留独立 Xvfb;外部 DISPLAY 模式允许共享。** + +停止一个浏览器时,外部模式没有由该 runtime 持有的 Xvfb unit,现有清理流程不会主动关闭外部 Xvfb。共享后的启动、恢复、停止、状态判断仍需完整验证。 + +## 5. 指定 profile 的两种输入方式 + +### 5.1 直接控制网页:CDP + +```text +profile_id → runtime → 独立 cdp_port → 页面 targetId +``` + +- `Page.bringToFront`:选择对应页面并请求置于前台。 +- `Input.dispatchMouseEvent`:向指定页面发送鼠标事件。 +- 事件坐标是主框架视口内的 CSS 像素,不是 Xvfb 桌面坐标。 +- 请求通过独立控制连接定位到对应 Chrome,无需按桌面位置寻找账号。 +- 这不等于控制系统鼠标,也不覆盖浏览器工具栏或原生系统弹窗。 +- 依赖前台焦点或页面可见性的行为仍需协调,不能笼统承诺所有操作均可并发。 + +**推荐:只需网页内输入时优先采用 CDP。** + +### 5.2 桌面输入:X11 + Openbox + +```text +profile_id → runtime.browser_pid → X11 窗口 ID → 激活窗口 → 桌面输入 +``` + +适用于需要真实桌面焦点、VNC 前台窗口或网页之外的操作。必须注意:同一个 DISPLAY 只有一套桌面鼠标和键盘焦点,不能把多个窗口理解为多个独立输入设备。 + +本文仅说明事件与目标窗口的对应机制,不涉及验证码识别、求解或自动绕过,也不保证网站接受合成输入。 + +## 6. Openbox 如何激活指定 profile 的窗口 + +### 6.1 职责和绑定关系 + +```text +Xvfb 提供虚拟屏幕,例如 :99 +Openbox 管理该屏幕上的窗口、桌面、遮挡顺序和焦点 +Chrome 创建窗口,并声明窗口所属进程 +gateway 保存 profile 对应的 Chrome 运行信息 +``` + +Openbox 不认识 `profile_id`,也不保存账号绑定。绑定关系由 gateway 查询或维护: + +```text +profile_id + → 当前 runtime 的 display、browser_pid、browser_start_time + → 同一 DISPLAY 上 _NET_WM_PID 匹配的顶层窗口 + → X11 窗口 ID + → 向 Openbox 发送激活请求 +``` + +当前每个 profile 使用独立的 `--user-data-dir` 和 Chrome 进程,这是按 PID 对应 profile 的前提。若未来改为多个 profile 共用同一 Chrome 进程,这条映射将不再足够,不能沿用结论。 + +一个 DISPLAY 通常运行一个窗口管理器。Xvfb 自身不提供窗口管理;若该 DISPLAY 尚无窗口管理器,可由部署方在该 DISPLAY 上运行 Openbox。gateway 目前不会因此自动具备管理 Openbox 生命周期的能力。 + +### 6.2 命令示例 + +以下 PID、窗口 ID 是示意值;本次没有实际执行激活。 + +假设运行记录是 `display=99`、`browser_pid=23456`: + +```bash +export DISPLAY=:99 + +# 查看窗口管理器。 +wmctrl -m + +# 查看顶层窗口:窗口 ID、桌面、PID、主机、标题。 +wmctrl -lp +``` + +示意输出: + +```text +0x01200003 0 23456 host 抖音 - Google Chrome +0x01400003 0 34567 host 抖音 - Google Chrome +``` + +第三列匹配目标 Chrome PID,因此候选窗口是 `0x01200003`。可进一步检查: + +```bash +xprop -id 0x01200003 _NET_WM_PID WM_CLASS +``` + +确认窗口唯一且仍属于目标 Chrome 后,请求激活: + +```bash +timeout 5s xdotool windowactivate --sync 0x01200003 +``` + +`xdotool` 通过 EWMH 窗口管理协议发送请求,Openbox 执行桌面切换、窗口置前和焦点分配;`--sync` 等待目标窗口成为激活窗口,外层超时避免无限等待。超时或失败必须报告,不能继续发送输入。 + +检查激活结果: + +```bash +xdotool getactivewindow +xdotool getactivewindow getwindowpid +``` + +不仅检查 PID,还应确认激活的是选定窗口;同一 PID 可能有多个窗口。 + +### 6.3 只读验证证据 + +本次讨论期间,在本机 `DISPLAY=:99` 上只读检查发现: + +- `wmctrl -m` 报告窗口管理器为 Openbox。 +- 一个 Chrome 窗口的 `_NET_WM_PID` 与实际 Chrome 进程 PID 一致。 +- 该 Chrome 进程命令行包含其独立 `--user-data-dir`。 +- 本地 `xdotool` 手册确认 `windowactivate --sync` 的激活等待行为。 + +这验证了环境与映射依据,**不代表 gateway 已经实现或验证按 profile 激活窗口**;本次未执行窗口切换、拖动或网页操作。 + +## 7. 窗口定位和输入的必要约束 + +1. **不按标题或窗口顺序绑定**:页面标题会变,不同 profile 可能打开相同页面。 +2. **重启后重新定位**:PID 和 X11 窗口 ID 都可能改变;校验进程启动时间,防止 PID 被复用。 +3. **多窗口必须明确目标**:一个 profile 可以有多个窗口。找不到窗口或找到多个候选时明确报错,不能自动取第一个。 +4. **窗口与标签页分开处理**:激活 Chrome 窗口不代表选中了指定标签页。指定页面使用 CDP 的 `targetId`。 +5. **两种窗口 ID 不能混用**:`Browser.getWindowForTarget` 返回的 CDP `windowId` 不等于 X11 窗口 ID。 +6. **同屏桌面操作串行**:协调范围至少覆盖“定位、激活、确认、按下、移动、释放”,不能只锁激活步骤。若存在多个控制进程,必须明确单一控制者或跨进程协调方式。 +7. **人工操作也会抢焦点**:VNC 使用者不受程序锁约束。操作期间应避免人工输入;检测到目标变化时中止并报告,不能声称程序锁能消除全部竞争。 +8. **错误必须可见**:窗口管理器缺失、窗口不存在、映射歧义、激活超时、焦点丢失均应报告。中断输入时应清理本次按键/按钮状态,不能静默改投其他窗口。 + +## 8. 后续迭代事项(待确认后实施) + +### A. 外部 DISPLAY 共享 + +- 调整外部模式的运行记录独占检查和 display lease 处理。 +- 保留默认独立模式的 Xvfb 分配和生命周期隔离。 +- 核查共享模式的启动、停止、释放、gateway 重启恢复、显示服务失效等路径。 +- 更新配置说明、相关测试及与行为发生冲突的既有文档。 + +### B. 按 profile 激活窗口 + +- 从当前 runtime 获取 DISPLAY、浏览器 PID 和进程启动时间。 +- 在对应 DISPLAY 上查询受管理的 Chrome 顶层窗口。 +- 定义多窗口时的目标选择规则;未确定规则前,不新增猜测或兜底行为。 +- 使用现有 X11 工具请求激活,并校验结果、设置超时、报告错误。 +- 窗口 ID 优先即时查询,不为短生命周期标识新增数据库绑定表。 + +### C. 输入方式及协调 + +- 根据实际需求明确采用网页 CDP 输入还是桌面 X11 输入,不把两者混作同一能力。 +- 桌面输入按 DISPLAY 协调整个操作过程,并明确人工 VNC 输入的处理约定。 +- 是否由 gateway 管理 Openbox、是否需要专用操作接口,由后续需求单独确认;当前不新增部署层或接口。 + +### D. 日志与资源验证 + +- 关键记录至少包括:操作编号、profile、runtime、DISPLAY、浏览器 PID/启动时间、目标窗口、激活前后状态、耗时和失败原因。 +- 区分“未找到窗口”“多个候选”“服务不可用”“激活失败”“焦点改变”,避免只记录通用失败。 +- 若节省内存是迭代目的,应在相同页面、相同 profile 数量与相同负载下对比独立/共享模式;统计 Xvfb、Chrome、Openbox、VNC 整体资源,而不是只累加 RSS。 + +## 9. 后续开发验收条件 + +功能开发遵循 TDD,相关单元测试覆盖率至少 65%;不主动编写或运行 E2E 测试。 + +### 单元测试应覆盖 + +- 默认模式为不同 runtime 分配独立 DISPLAY。 +- 共享外部模式允许多个 runtime 使用同一 DISPLAY,且保留独立数据目录和控制端口。 +- 停止一个 runtime 不关闭外部 Xvfb,不影响另一个 runtime。 +- gateway 恢复时正确识别共享 DISPLAY 与各自浏览器状态。 +- 外部 DISPLAY 不可用时明确报错,不自动切换到其他屏幕。 +- profile/PID/窗口定位成功、窗口缺失、多窗口歧义、PID 失效和窗口失效。 +- 激活成功、超时、窗口管理器缺失与结果不匹配。 +- 同一 DISPLAY 的桌面输入不会交叉执行;失败能释放协调资源并报告。 + +### 需要使用者明确授权的现场验证 + +- 两个独立 profile 共用一个 Xvfb,确认窗口和账号数据互不混淆。 +- 按 profile 激活窗口,包含窗口被遮挡、位于其他桌面以及重启后的情形。 +- 在普通可拖动页面验证目标窗口与输入坐标,避免以验证码求解作为验证标准。 +- 检查停止其中一个浏览器后另一浏览器和外部 Xvfb 是否正常。 +- 记录相同负载下的资源变化及日志证据。 + +**实施前仍需确认:是否启用共享外部 DISPLAY、是否需要桌面输入、多窗口选择规则、Openbox 由谁管理。本文不替代这些决策。**